Merge branch 'refs/heads/main_production_ai_enhancement' into main_production_ai_enhancement_rollout_changes
# Conflicts: # lib/controllers/api_routes/urls.dart # lib/controllers/providers/api/all_requests_provider.dartmain_production_ai_enhancement
commit
9574937596
@ -0,0 +1,6 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.10515e-06 1C7.10515e-06 0.447715 0.447722 0 1.00001 0H17C17.5523 0 18 0.447715 18 1C18 1.55228 17.5523 2 17 2H1.00001C0.447722 2 7.10515e-06 1.55228 7.10515e-06 1Z" fill="#212936"/>
|
||||
<path d="M1.00001 6.00001C0.447722 6 3.92408e-06 6.44772 0 7C-3.92403e-06 7.55228 0.447708 8 0.999993 8.00001L12 8.00008C12.5523 8.00009 13 7.55238 13 7.00009C13 6.44781 12.5523 6.00009 12 6.00009L1.00001 6.00001Z" fill="#212936"/>
|
||||
<path d="M1.00001 12C0.447722 12 7.10515e-06 12.4477 7.10515e-06 13C7.10515e-06 13.5523 0.447722 14 1.00001 14H8.00001C8.55229 14 9.00001 13.5523 9.00001 13C9.00001 12.4477 8.55229 12 8.00001 12H1.00001Z" fill="#212936"/>
|
||||
<path d="M15.1616 15.5C14.986 15.4999 14.7703 15.4997 14.5939 15.5218L14.5905 15.5222C14.4641 15.538 13.888 15.6098 13.6137 16.1754C13.3387 16.7422 13.6407 17.2424 13.706 17.3506L14.0451 17.819C14.3398 18.1949 14.7455 18.7093 15.1259 19.1003C15.3157 19.2954 15.533 19.4967 15.7639 19.6555C15.9691 19.7967 16.3193 20 16.75 20C17.1807 20 17.5309 19.7967 17.7361 19.6555C17.967 19.4967 18.1843 19.2954 18.3741 19.1003C18.7546 18.7093 19.1603 18.1949 19.4549 17.8191L19.794 17.3506C19.8594 17.2424 20.1613 16.7422 19.8863 16.1754C19.612 15.6098 19.0359 15.538 18.9095 15.5222L18.9061 15.5218C18.7297 15.4997 18.514 15.4999 18.3384 15.5L17.75 15.5V6.99996C17.75 6.44768 17.3023 5.99996 16.75 5.99996C16.1977 5.99996 15.75 6.44768 15.75 6.99996V15.5L15.1616 15.5Z" fill="#212936"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -0,0 +1,55 @@
|
||||
import 'package:test_sa/models/base.dart';
|
||||
|
||||
class DepartmentRoomCombined extends Base {
|
||||
final int? deptId;
|
||||
final int? roomId;
|
||||
final String? deptName;
|
||||
final String? roomName;
|
||||
final String? locationDescriptionName;
|
||||
final String? deptAndRoomName;
|
||||
|
||||
DepartmentRoomCombined({
|
||||
this.deptId,
|
||||
this.roomId,
|
||||
this.deptName,
|
||||
this.roomName,
|
||||
this.locationDescriptionName,
|
||||
this.deptAndRoomName,
|
||||
}) : super(
|
||||
identifier: '${deptId}_${roomId}',
|
||||
name: deptAndRoomName ?? '${deptName ?? ''} / ${roomName ?? ''}',
|
||||
);
|
||||
|
||||
factory DepartmentRoomCombined.fromJson(Map<String, dynamic> json) {
|
||||
return DepartmentRoomCombined(
|
||||
deptId: json['deptId'],
|
||||
roomId: json['roomId'],
|
||||
deptName: json['deptName'],
|
||||
roomName: json['roomName'],
|
||||
locationDescriptionName: json['locationDescriptionName'],
|
||||
deptAndRoomName: json['deptAndRoomName'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'deptId': deptId,
|
||||
'roomId': roomId,
|
||||
'deptName': deptName,
|
||||
'roomName': roomName,
|
||||
'locationDescriptionName': locationDescriptionName,
|
||||
'deptAndRoomName': deptAndRoomName,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is DepartmentRoomCombined &&
|
||||
runtimeType == other.runtimeType &&
|
||||
deptId == other.deptId &&
|
||||
roomId == other.roomId;
|
||||
|
||||
@override
|
||||
int get hashCode => deptId.hashCode ^ roomId.hashCode;
|
||||
}
|
||||
@ -0,0 +1,73 @@
|
||||
class CleanerTaskSearchResult {
|
||||
int? id;
|
||||
String? taskNo;
|
||||
String? siteName;
|
||||
String? buildingName;
|
||||
String? floorName;
|
||||
String? departmentName;
|
||||
String? roomName;
|
||||
String? locationDescription;
|
||||
String? createdDate;
|
||||
|
||||
CleanerTaskSearchResult({
|
||||
this.id,
|
||||
this.taskNo,
|
||||
this.siteName,
|
||||
this.buildingName,
|
||||
this.floorName,
|
||||
this.departmentName,
|
||||
this.roomName,
|
||||
this.locationDescription,
|
||||
this.createdDate,
|
||||
});
|
||||
|
||||
factory CleanerTaskSearchResult.fromJson(Map<String, dynamic> json) {
|
||||
return CleanerTaskSearchResult(
|
||||
id: json['id'],
|
||||
taskNo: json['taskNo'],
|
||||
siteName: json['siteName'],
|
||||
buildingName: json['buildingName'],
|
||||
floorName: json['floorName'],
|
||||
departmentName: json['departmentName'],
|
||||
roomName: json['roomName'],
|
||||
locationDescription: json['locationDescription'],
|
||||
createdDate: json['createdDate'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'taskNo': taskNo,
|
||||
'siteName': siteName,
|
||||
'buildingName': buildingName,
|
||||
'floorName': floorName,
|
||||
'departmentName': departmentName,
|
||||
'roomName': roomName,
|
||||
'locationDescription': locationDescription,
|
||||
'createdDate': createdDate,
|
||||
};
|
||||
}
|
||||
|
||||
String get fullLocation {
|
||||
List<String> parts = [];
|
||||
if (siteName != null && siteName!.isNotEmpty) parts.add(siteName!);
|
||||
if (buildingName != null && buildingName!.isNotEmpty) parts.add(buildingName!);
|
||||
if (floorName != null && floorName!.isNotEmpty) parts.add(floorName!);
|
||||
if (departmentName != null && departmentName!.isNotEmpty) parts.add(departmentName!);
|
||||
if (roomName != null && roomName!.isNotEmpty) parts.add('Room $roomName');
|
||||
return parts.join(' - ');
|
||||
}
|
||||
|
||||
String get departmentRoom {
|
||||
if (departmentName != null && roomName != null) {
|
||||
return '$departmentName / $roomName';
|
||||
} else if (departmentName != null) {
|
||||
return departmentName!;
|
||||
} else if (roomName != null) {
|
||||
return 'Room $roomName';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,247 @@
|
||||
class OneTimeTaskData {
|
||||
int? id;
|
||||
int? oneTimeSettingId;
|
||||
OneTimeSetting? oneTimeSetting;
|
||||
String? userId;
|
||||
String? userName;
|
||||
int? siteId;
|
||||
String? siteName;
|
||||
int? buildingId;
|
||||
String? buildingName;
|
||||
int? floorId;
|
||||
String? floorName;
|
||||
int? departmentID;
|
||||
String? departmentName;
|
||||
int? roomId;
|
||||
String? roomName;
|
||||
String? locationDescription;
|
||||
String? startTime;
|
||||
String? endTime;
|
||||
int? statusId;
|
||||
String? statusName;
|
||||
int? statusValue;
|
||||
List<Instruction>? instructions;
|
||||
List<Attachment>? attachments;
|
||||
String? createdDate;
|
||||
String? createdBy;
|
||||
String? imageRoom; // For backward compatibility - deprecated
|
||||
List<String>? imageRooms; // For multiple captured images (max 2)
|
||||
|
||||
OneTimeTaskData({
|
||||
this.id,
|
||||
this.oneTimeSettingId,
|
||||
this.oneTimeSetting,
|
||||
this.userId,
|
||||
this.userName,
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.buildingId,
|
||||
this.buildingName,
|
||||
this.floorId,
|
||||
this.floorName,
|
||||
this.departmentID,
|
||||
this.departmentName,
|
||||
this.roomId,
|
||||
this.roomName,
|
||||
this.locationDescription,
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
this.statusId,
|
||||
this.statusName,
|
||||
this.statusValue,
|
||||
this.instructions,
|
||||
this.attachments,
|
||||
this.createdDate,
|
||||
this.createdBy,
|
||||
this.imageRoom,
|
||||
this.imageRooms,
|
||||
});
|
||||
|
||||
factory OneTimeTaskData.fromJson(Map<String, dynamic> json) {
|
||||
return OneTimeTaskData(
|
||||
id: json['id'],
|
||||
oneTimeSettingId: json['oneTimeSettingId'],
|
||||
oneTimeSetting: json['oneTimeSetting'] != null ? OneTimeSetting.fromJson(json['oneTimeSetting']) : null,
|
||||
userId: json['userId'],
|
||||
userName: json['userName'],
|
||||
siteId: json['siteId'],
|
||||
siteName: json['siteName'],
|
||||
buildingId: json['buildingId'],
|
||||
buildingName: json['buildingName'],
|
||||
floorId: json['floorId'],
|
||||
floorName: json['floorName'],
|
||||
departmentID: json['departmentID'],
|
||||
departmentName: json['departmentName'],
|
||||
roomId: json['roomId'],
|
||||
roomName: json['roomName'],
|
||||
locationDescription: json['locationDescription'],
|
||||
startTime: json['startTime'],
|
||||
endTime: json['endTime'],
|
||||
statusId: json['statusId'],
|
||||
statusName: json['statusName'],
|
||||
statusValue: json['statusValue'],
|
||||
instructions: json['instructions'] != null
|
||||
? (json['instructions'] as List).map((i) => Instruction.fromJson(i)).toList()
|
||||
: null,
|
||||
attachments: json['attachments'] != null
|
||||
? (json['attachments'] as List).map((a) => Attachment.fromJson(a)).toList()
|
||||
: null,
|
||||
createdDate: json['createdDate'],
|
||||
createdBy: json['createdBy'],
|
||||
imageRoom: json['imageRoom'],
|
||||
imageRooms: json['imageRooms'] != null ? List<String>.from(json['imageRooms']) : null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'oneTimeSettingId': oneTimeSettingId,
|
||||
'oneTimeSetting': oneTimeSetting?.toJson(),
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'siteId': siteId,
|
||||
'siteName': siteName,
|
||||
'buildingId': buildingId,
|
||||
'buildingName': buildingName,
|
||||
'floorId': floorId,
|
||||
'floorName': floorName,
|
||||
'departmentID': departmentID,
|
||||
'departmentName': departmentName,
|
||||
'roomId': roomId,
|
||||
'roomName': roomName,
|
||||
'locationDescription': locationDescription,
|
||||
'startTime': startTime,
|
||||
'endTime': endTime,
|
||||
'statusId': statusId,
|
||||
'statusName': statusName,
|
||||
'statusValue': statusValue,
|
||||
'instructions': instructions?.map((i) => i.toJson()).toList(),
|
||||
'attachments': attachments?.map((a) => a.toJson()).toList(),
|
||||
'createdDate': createdDate,
|
||||
'createdBy': createdBy,
|
||||
'imageRoom': imageRoom,
|
||||
'imageRooms': imageRooms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class OneTimeSetting {
|
||||
int? id;
|
||||
int? taskTypeId;
|
||||
String? taskTypeName;
|
||||
int? instructionDescriptionId;
|
||||
String? instructionDescriptionName;
|
||||
int? sla;
|
||||
|
||||
OneTimeSetting({
|
||||
this.id,
|
||||
this.taskTypeId,
|
||||
this.taskTypeName,
|
||||
this.instructionDescriptionId,
|
||||
this.instructionDescriptionName,
|
||||
this.sla,
|
||||
});
|
||||
|
||||
factory OneTimeSetting.fromJson(Map<String, dynamic> json) {
|
||||
return OneTimeSetting(
|
||||
id: json['id'],
|
||||
taskTypeId: json['taskTypeId'],
|
||||
taskTypeName: json['taskTypeName'],
|
||||
instructionDescriptionId: json['instructionDescriptionId'],
|
||||
instructionDescriptionName: json['instructionDescriptionName'],
|
||||
sla: json['sla'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'taskTypeId': taskTypeId,
|
||||
'taskTypeName': taskTypeName,
|
||||
'instructionDescriptionId': instructionDescriptionId,
|
||||
'instructionDescriptionName': instructionDescriptionName,
|
||||
'sla': sla,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Instruction {
|
||||
int? id;
|
||||
int? instructionTextId;
|
||||
String? instructionText;
|
||||
bool? isRequired;
|
||||
bool? isDone;
|
||||
String? comment;
|
||||
|
||||
Instruction({
|
||||
this.id,
|
||||
this.instructionTextId,
|
||||
this.instructionText,
|
||||
this.isRequired,
|
||||
this.isDone,
|
||||
this.comment,
|
||||
});
|
||||
|
||||
factory Instruction.fromJson(Map<String, dynamic> json) {
|
||||
return Instruction(
|
||||
id: json['id'],
|
||||
instructionTextId: json['instructionTextId'],
|
||||
instructionText: json['instructionText'],
|
||||
isRequired: json['isRequired'],
|
||||
isDone: json['isDone'],
|
||||
comment: json['comment'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'instructionTextId': instructionTextId,
|
||||
'instructionText': instructionText,
|
||||
'isRequired': isRequired,
|
||||
'isDone': isDone,
|
||||
'comment': comment,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Attachment {
|
||||
int? id;
|
||||
String? name;
|
||||
String? originalName;
|
||||
String? fileName; // For backward compatibility
|
||||
String? fileUrl; // For backward compatibility
|
||||
String? fileType;
|
||||
|
||||
Attachment({
|
||||
this.id,
|
||||
this.name,
|
||||
this.originalName,
|
||||
this.fileName,
|
||||
this.fileUrl,
|
||||
this.fileType,
|
||||
});
|
||||
|
||||
factory Attachment.fromJson(Map<String, dynamic> json) {
|
||||
return Attachment(
|
||||
id: json['id'],
|
||||
name: json['name'],
|
||||
originalName: json['originalName'],
|
||||
fileName: json['fileName'],
|
||||
fileUrl: json['fileUrl'],
|
||||
fileType: json['fileType'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'originalName': originalName,
|
||||
'fileName': fileName,
|
||||
'fileUrl': fileUrl,
|
||||
'fileType': fileType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
class QrScanLocationData {
|
||||
int? siteId;
|
||||
String? siteName;
|
||||
int? buildingId;
|
||||
String? buildingName;
|
||||
int? floorId;
|
||||
String? floorName;
|
||||
int? departmentId;
|
||||
String? departmentName;
|
||||
int? roomId;
|
||||
String? roomName;
|
||||
String? locationDescription;
|
||||
|
||||
QrScanLocationData({
|
||||
this.siteId,
|
||||
this.siteName,
|
||||
this.buildingId,
|
||||
this.buildingName,
|
||||
this.floorId,
|
||||
this.floorName,
|
||||
this.departmentId,
|
||||
this.departmentName,
|
||||
this.roomId,
|
||||
this.roomName,
|
||||
this.locationDescription,
|
||||
});
|
||||
|
||||
factory QrScanLocationData.fromJson(Map<String, dynamic> json) {
|
||||
return QrScanLocationData(
|
||||
siteId: json['siteId'],
|
||||
siteName: json['siteName'],
|
||||
buildingId: json['buildingId'],
|
||||
buildingName: json['buildingName'],
|
||||
floorId: json['floorId'],
|
||||
floorName: json['floorName'],
|
||||
departmentId: json['departmentId'],
|
||||
departmentName: json['departmentName'],
|
||||
roomId: json['roomId'],
|
||||
roomName: json['roomName'],
|
||||
locationDescription: json['locationDescription'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'siteId': siteId,
|
||||
'siteName': siteName,
|
||||
'buildingId': buildingId,
|
||||
'buildingName': buildingName,
|
||||
'floorId': floorId,
|
||||
'floorName': floorName,
|
||||
'departmentId': departmentId,
|
||||
'departmentName': departmentName,
|
||||
'roomId': roomId,
|
||||
'roomName': roomName,
|
||||
'locationDescription': locationDescription,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
import 'package:test_sa/models/base.dart';
|
||||
|
||||
class TaskType extends Base {
|
||||
int? id;
|
||||
String? taskTypeName;
|
||||
|
||||
TaskType({this.id, this.taskTypeName}) : super(identifier: id?.toString(), name: taskTypeName);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is TaskType && id != null && id == other.id;
|
||||
|
||||
@override
|
||||
int get hashCode => id.hashCode;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {"id": id, "taskTypeName": taskTypeName};
|
||||
}
|
||||
|
||||
factory TaskType.fromJson(Map<String, dynamic>? parsedJson) {
|
||||
if (parsedJson == null) return TaskType();
|
||||
return TaskType(
|
||||
id: parsedJson["id"],
|
||||
taskTypeName: parsedJson["taskTypeName"],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,413 @@
|
||||
// UI: Cleaner Search Details View
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.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/models/ppm/recurrent_wo.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/app_lazy_loading.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/views/widgets/qr/scan_qr.dart';
|
||||
import 'package:test_sa/views/widgets/requests/request_status.dart';
|
||||
import '../models/cleaner_task_search_result.dart';
|
||||
import '../provider/cleaner_provider.dart';
|
||||
import '../../pm_module/recurrent_wo/recurrent_work_order_view.dart';
|
||||
import 'package:test_sa/controllers/providers/api/all_requests_provider.dart';
|
||||
import 'package:test_sa/views/widgets/dialogs/confirm_dialog.dart';
|
||||
import 'package:test_sa/controllers/api_routes/api_manager.dart';
|
||||
import 'package:test_sa/controllers/api_routes/urls.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class CleanerSearchDetailsView extends StatefulWidget {
|
||||
static const String routeName = '/cleaner_search_details';
|
||||
final int requestId;
|
||||
final CleanerTaskSearchResult? taskResult;
|
||||
|
||||
const CleanerSearchDetailsView({
|
||||
Key? key,
|
||||
required this.requestId,
|
||||
this.taskResult,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_CleanerSearchDetailsViewState createState() => _CleanerSearchDetailsViewState();
|
||||
}
|
||||
|
||||
class _CleanerSearchDetailsViewState extends State<CleanerSearchDetailsView> {
|
||||
late CleanerProvider _cleanerProvider;
|
||||
|
||||
// bool _isQRScanned = false;
|
||||
|
||||
bool _isRequestOpen = false;
|
||||
CleanerTaskSearchResult? _taskData;
|
||||
RecurrentWoData? _recurrentWoData;
|
||||
bool _isLoading = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cleanerProvider = Provider.of<CleanerProvider>(context, listen: false);
|
||||
_taskData = widget.taskResult;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
getInitialData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> getInitialData() async {
|
||||
// If taskResult is null, fetch data from recurrent work order API
|
||||
if (widget.taskResult == null) {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Use direct API call to get better error messages
|
||||
final result = await _getRecurrentWoByIdWithMessage(widget.requestId);
|
||||
|
||||
if (result["success"] == true && result["data"] != null) {
|
||||
_recurrentWoData = result["data"];
|
||||
_isRequestOpen = _recurrentWoData?.status?.value == 4;
|
||||
_taskData = CleanerTaskSearchResult(
|
||||
id: _recurrentWoData!.id,
|
||||
taskNo: _recurrentWoData!.taskNo,
|
||||
siteName: _recurrentWoData!.site?.siteName,
|
||||
buildingName: _recurrentWoData!.building?.name,
|
||||
floorName: _recurrentWoData!.floor?.name,
|
||||
departmentName: _recurrentWoData!.department?.name,
|
||||
roomName: _recurrentWoData!.room?.name,
|
||||
locationDescription: null,
|
||||
createdDate: _recurrentWoData!.scheduleDate,
|
||||
);
|
||||
} else {
|
||||
// Show exact backend error message
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, result["message"] ?? "Failed to load request details. Please try again.");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching recurrent work order: $e');
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, "Failed to load request details. Please check your connection and try again.");
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await _cleanerProvider.getCleanerRequestById(id: widget.requestId);
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching cleaner request: $e');
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, "Failed to load request details. Please check your connection and try again.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
appBar: DefaultAppBar(
|
||||
title: 'Request Details'.addTranslation,
|
||||
onBackPress: () => Navigator.pop(context),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SafeArea(
|
||||
child: Selector<CleanerProvider, bool>(
|
||||
selector: (_, model) => model.isLoading,
|
||||
builder: (_, isLoading, __) {
|
||||
if (isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildCleanerRequestDetailCard(context),
|
||||
],
|
||||
),
|
||||
).expanded,
|
||||
FooterActionButton.footerContainer(
|
||||
context: context,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isRequestOpen) ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline_rounded, size: 16, color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
8.width,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'By scanning the QR code you will accept this task'.addTranslation,
|
||||
// textAlign: TextAlign.center,
|
||||
style: AppTextStyles.bodyText2.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
8.height,
|
||||
],
|
||||
AppFilledButton(
|
||||
label: _isRequestOpen ? 'Scan QR Code' : 'Update',
|
||||
maxWidth: true,
|
||||
buttonColor: AppColor.primary10,
|
||||
onPressed: () async {
|
||||
if (_isRequestOpen) {
|
||||
final result = await Navigator.push(context, MaterialPageRoute(builder: (context) => const ScanQr()));
|
||||
if (result != null && result is String && result.isNotEmpty) {
|
||||
showDialog(context: context, barrierDismissible: false, builder: (context) => const AppLazyLoading());
|
||||
|
||||
try {
|
||||
final qrResult = await CleanerProvider().checkQRSameRoomWithMessage(planRecurrentTaskId: widget.requestId, qrCode: result);
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
final isValid = qrResult["success"] ?? false;
|
||||
final backendMessage = qrResult["message"] ?? "Unknown error occurred";
|
||||
|
||||
if (isValid) {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(builder: (_) => RecurrentWorkOrderView(taskId: widget.requestId)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
// Show exact backend error message instead of our custom message
|
||||
_showErrorDialog(context, backendMessage);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
if (mounted) {
|
||||
// Extract actual error message from the exception or use generic fallback
|
||||
String errorMessage = _extractErrorMessage(e) ?? 'Failed to validate QR code. Please try again.';
|
||||
_showErrorDialog(context, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => RecurrentWorkOrderView(taskId: widget.requestId)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
).toShadowContainer(context, padding: 0, showShadow: false, borderRadius: 0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCleanerRequestDetailCard(BuildContext context) {
|
||||
final taskResult = _taskData;
|
||||
|
||||
if (taskResult == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Task Number and Date Row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Text(
|
||||
// 'Request Information',
|
||||
// style: AppTextStyles.heading4.copyWith(
|
||||
// color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
// ),
|
||||
// ),
|
||||
if (_recurrentWoData?.status?.name != null)
|
||||
StatusLabel(
|
||||
label: _recurrentWoData?.status?.name!,
|
||||
textColor: AppColor.getRequestStatusTextColorByName(context, _recurrentWoData?.status?.name!),
|
||||
backgroundColor: AppColor.getRequestStatusColorByName(context, _recurrentWoData?.status?.name!),
|
||||
),
|
||||
8.width,
|
||||
// StatusLabel(
|
||||
// label: taskModel?.taskJobStatus?.name ?? widget.requestDetails?.status!,
|
||||
// textColor: AppColor.getRequestStatusTextColorByName(context, taskModel?.taskJobStatus?.name ?? widget.requestDetails?.status!),
|
||||
// backgroundColor: AppColor.getRequestStatusColorByName(context, taskModel?.taskJobStatus?.name ?? widget.requestDetails?.status!),
|
||||
// ),
|
||||
if (taskResult.createdDate != null)
|
||||
Text(
|
||||
taskResult.createdDate!.toServiceRequestCardFormat,
|
||||
textAlign: TextAlign.end,
|
||||
style: AppTextStyles.tinyFont.copyWith(color: context.isDark ? AppColor.neutral30 : AppColor.neutral50),
|
||||
),
|
||||
],
|
||||
),
|
||||
8.height,
|
||||
if (_recurrentWoData?.title?.isNotEmpty ?? false)
|
||||
Text(
|
||||
_recurrentWoData?.title ?? "",
|
||||
style: AppTextStyles.heading4.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
if (taskResult.taskNo != null && taskResult.taskNo!.isNotEmpty)
|
||||
Text(
|
||||
'Task No: ${taskResult.taskNo!}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.siteName != null && taskResult.siteName!.isNotEmpty)
|
||||
Text(
|
||||
'Site: ${taskResult.siteName}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.buildingName != null && taskResult.buildingName!.isNotEmpty)
|
||||
Text(
|
||||
'Building: ${taskResult.buildingName}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.floorName != null && taskResult.floorName!.isNotEmpty)
|
||||
Text(
|
||||
'Floor: ${taskResult.floorName}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.departmentName != null && taskResult.departmentName!.isNotEmpty)
|
||||
Text(
|
||||
'Department: ${taskResult.departmentName}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.roomName != null && taskResult.roomName!.isNotEmpty)
|
||||
Text(
|
||||
'Room: ${taskResult.roomName}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
if (taskResult.locationDescription != null && taskResult.locationDescription!.isNotEmpty)
|
||||
Text(
|
||||
'Location: ${taskResult.locationDescription}',
|
||||
style: AppTextStyles.bodyText.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral120),
|
||||
),
|
||||
],
|
||||
).expanded,
|
||||
],
|
||||
),
|
||||
],
|
||||
).paddingOnly(start: 16, end: 16, top: 16, bottom: 8),
|
||||
],
|
||||
).toShadowContainer(context, padding: 0);
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
message: message,
|
||||
title: 'Error',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _extractErrorMessage(dynamic error) {
|
||||
// Implement your logic to extract error message from the exception
|
||||
// For example, if the error is a custom exception with a message property:
|
||||
if (error is ExceptionWithMessage) {
|
||||
return error.message;
|
||||
} else if (error is String) {
|
||||
// If the error is a string, return it directly
|
||||
return error;
|
||||
} else if (error is Map<String, dynamic>) {
|
||||
// If the error is a Map (e.g., parsed JSON), extract the message field
|
||||
return error['message'] ?? error.toString();
|
||||
}
|
||||
|
||||
// Fallback to a generic error message
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
|
||||
/// Custom method to get recurrent work order with proper error message extraction
|
||||
Future<Map<String, dynamic>> _getRecurrentWoByIdWithMessage(int id) async {
|
||||
try {
|
||||
final response = await ApiManager.instance.get(
|
||||
URLs.getRecurrentPlanByIdUrl + "?planRecurrentTaskId=$id",
|
||||
enableToastMessage: false, // Disable automatic toast
|
||||
);
|
||||
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// Success response
|
||||
final recurrentWoData = RecurrentWoData.fromJson(responseData["data"]);
|
||||
|
||||
// Apply the same processing as in AllRequestsProvider
|
||||
recurrentWoData.planRecurrentMedicalTaskRooms?.forEach((element) {
|
||||
element.planRecurrentMedicalTaskRoomTabs?.forEach((tabs) {
|
||||
tabs.planRecurrentMedicalTaskRoomTabAttributes?.forEach((attribute) {
|
||||
if (attribute.attribute?.type == "bool" && attribute.attributeValue == null) {
|
||||
attribute.attributeValue = "true";
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
"success": true,
|
||||
"data": recurrentWoData,
|
||||
"message": "Data loaded successfully",
|
||||
};
|
||||
} else {
|
||||
// Error response - extract the exact error message from backend
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": responseData["message"] ?? "Failed to load request details",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error in _getRecurrentWoByIdWithMessage: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Failed to load request details. Please check your connection and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ExceptionWithMessage implements Exception {
|
||||
final String message;
|
||||
|
||||
ExceptionWithMessage(this.message);
|
||||
}
|
||||
@ -0,0 +1,359 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:test_sa/extensions/context_extension.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/models/task_type.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/models/qr_scan_location_data.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/models/one_time_task_data.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/provider/cleaner_provider.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/provider/task_type_provider.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/pages/one_time_task_view.dart';
|
||||
import 'package:test_sa/modules/cm_module/views/components/action_button/footer_action_button.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/app_filled_button.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/single_item_drop_down_menu.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/providers/loading_list_notifier.dart';
|
||||
import 'package:test_sa/views/widgets/dialogs/confirm_dialog.dart';
|
||||
import 'package:test_sa/views/widgets/qr/scan_qr.dart';
|
||||
|
||||
class CleanerSearchFormView extends StatefulWidget {
|
||||
static const String routeName = '/cleaner_search';
|
||||
|
||||
const CleanerSearchFormView({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CleanerSearchFormView> createState() => _CleanerSearchFormViewState();
|
||||
}
|
||||
|
||||
class _CleanerSearchFormViewState extends State<CleanerSearchFormView> {
|
||||
TaskType? selectedTaskType;
|
||||
List<TaskType> _taskTypeOptions = [];
|
||||
bool _isLoadingTaskTypes = false;
|
||||
bool _isScanning = false;
|
||||
bool _isProceeding = false;
|
||||
QrScanLocationData? _scannedLocationData;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadTaskTypes();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadTaskTypes() async {
|
||||
setState(() {
|
||||
_isLoadingTaskTypes = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final taskTypes = await TaskTypeProvider().getTaskTypesList();
|
||||
setState(() {
|
||||
_taskTypeOptions = taskTypes;
|
||||
_isLoadingTaskTypes = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoadingTaskTypes = false;
|
||||
_taskTypeOptions = [];
|
||||
});
|
||||
_showErrorDialog(context, 'Failed to load task types. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onScanQRCode() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const ScanQr()),
|
||||
);
|
||||
|
||||
if (result != null && result is String && result.isNotEmpty) {
|
||||
setState(() {
|
||||
_isScanning = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final qrResult = await CleanerProvider().scanQRCode(qrCode: result);
|
||||
|
||||
final isSuccess = qrResult["success"] ?? false;
|
||||
final backendMessage = qrResult["message"] ?? "Unknown error occurred";
|
||||
|
||||
if (isSuccess && qrResult["data"] != null) {
|
||||
// Store the scanned location data
|
||||
setState(() {
|
||||
_scannedLocationData = QrScanLocationData.fromJson(qrResult["data"]);
|
||||
_isScanning = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, backendMessage);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isScanning = false;
|
||||
});
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, 'Failed to scan QR code. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onProceed() async {
|
||||
// Validate that task type and location data are available
|
||||
if (selectedTaskType == null) {
|
||||
_showErrorDialog(context, 'Please select a task type');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_scannedLocationData == null) {
|
||||
_showErrorDialog(context, 'Please scan a QR code first');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isProceeding = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await CleanerProvider().proceedOneTimeTask(
|
||||
oneTimeSettingId: selectedTaskType!.id ?? 0,
|
||||
siteId: _scannedLocationData!.siteId ?? 0,
|
||||
buildingId: _scannedLocationData!.buildingId ?? 0,
|
||||
floorId: _scannedLocationData!.floorId ?? 0,
|
||||
departmentId: _scannedLocationData!.departmentId ?? 0,
|
||||
roomId: _scannedLocationData!.roomId ?? 0,
|
||||
);
|
||||
|
||||
final isSuccess = result["success"] ?? false;
|
||||
final data = result["data"];
|
||||
final message = result["message"] ?? "Unknown error occurred";
|
||||
|
||||
if (isSuccess && data != null) {
|
||||
// Get the task ID from the response
|
||||
final taskId = data['id'];
|
||||
|
||||
if (mounted && taskId != null) {
|
||||
// Convert the data to OneTimeTaskData and navigate with the data
|
||||
final taskData = OneTimeTaskData.fromJson(data);
|
||||
|
||||
// Navigate to OneTimeTaskView with the data
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OneTimeTaskView(
|
||||
taskId: taskId,
|
||||
initialData: taskData,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, 'Failed to proceed. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setState(() {
|
||||
_isProceeding = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: DefaultAppBar(
|
||||
title: 'Search Request',
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Task Type Dropdown
|
||||
SingleItemDropDownMenu<TaskType, NullableLoadingProvider>(
|
||||
context: context,
|
||||
title: 'Task Type',
|
||||
initialValue: selectedTaskType,
|
||||
showShadow: false,
|
||||
showAsBottomSheet: true,
|
||||
backgroundColor: AppColor.fieldBgColor(context),
|
||||
enabled: _taskTypeOptions.isNotEmpty,
|
||||
loading: _isLoadingTaskTypes,
|
||||
staticData: _taskTypeOptions,
|
||||
onSelect: (value) {
|
||||
setState(() {
|
||||
selectedTaskType = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Scan QR Code Button
|
||||
GestureDetector(
|
||||
onTap: selectedTaskType != null ? _onScanQRCode : null,
|
||||
child: Container(
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selectedTaskType != null ? Colors.white : Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selectedTaskType != null ? AppColor.blueStatus(context) : Colors.grey,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(horizontal: 16.toScreenWidth, vertical: 8.toScreenHeight),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
"scan_asset".toSvgAsset(
|
||||
height: 22,
|
||||
fit: BoxFit.fitHeight,
|
||||
color: selectedTaskType != null
|
||||
? (context.isDark ? AppColor.neutral20 : AppColor.neutral120)
|
||||
: Colors.grey,
|
||||
),
|
||||
8.width,
|
||||
//we can change the implementation when merge with uiux branch
|
||||
_scannedLocationData==null? "Scan QR Code".bodyText(context).custom(
|
||||
color: selectedTaskType != null
|
||||
? (context.isDark ? AppColor.neutral20 : AppColor.black20)
|
||||
: Colors.grey,
|
||||
):'Re Scan QR Code'.bodyText(context).custom(
|
||||
color: selectedTaskType != null
|
||||
? (context.isDark ? AppColor.neutral20 : AppColor.black20)
|
||||
: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Loading Indicator
|
||||
if (_isScanning)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
|
||||
// Scanned Location Data Card
|
||||
if (_scannedLocationData != null && !_isScanning) _buildLocationDataCard(context),
|
||||
],
|
||||
).toShadowContainer(context).paddingOnly(top: 20, start: 16, end: 16),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer Action Button
|
||||
FooterActionButton.footerContainer(
|
||||
context: context,
|
||||
child: AppFilledButton(
|
||||
buttonColor:_scannedLocationData != null && !_isProceeding? AppColor.primary10: Colors.grey,
|
||||
label: 'Proceed',
|
||||
maxWidth: true,
|
||||
onPressed: (_scannedLocationData != null && !_isProceeding) ? _onProceed : null,
|
||||
loading: _isProceeding,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLocationDataCard(BuildContext context) {
|
||||
final locationData = _scannedLocationData;
|
||||
|
||||
if (locationData == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (locationData.siteName != null && locationData.siteName!.isNotEmpty)
|
||||
Text(
|
||||
'Site: ${locationData.siteName}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
if (locationData.buildingName != null && locationData.buildingName!.isNotEmpty)
|
||||
Text(
|
||||
'Building: ${locationData.buildingName}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
if (locationData.floorName != null && locationData.floorName!.isNotEmpty)
|
||||
Text(
|
||||
'Floor: ${locationData.floorName}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
if (locationData.departmentName != null && locationData.departmentName!.isNotEmpty)
|
||||
Text(
|
||||
'Department: ${locationData.departmentName}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
if (locationData.roomName != null && locationData.roomName!.isNotEmpty)
|
||||
Text(
|
||||
'Room: ${locationData.roomName}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
if (locationData.locationDescription != null && locationData.locationDescription!.isNotEmpty)
|
||||
Text(
|
||||
'Location Description: ${locationData.locationDescription}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
softWrap: true,
|
||||
maxLines: null,
|
||||
),
|
||||
],
|
||||
).paddingOnly(start: 16, end: 16, top: 16, bottom: 16),
|
||||
],
|
||||
).toShadowContainer(context, padding: 0);
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
message: message,
|
||||
title: 'Error',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,410 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:test_sa/extensions/context_extension.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/models/one_time_task_data.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/controllers/api_routes/urls.dart';
|
||||
import 'package:test_sa/views/widgets/images/files_list.dart';
|
||||
import 'package:test_sa/modules/cm_module/cm_request_utils.dart';
|
||||
import 'package:test_sa/views/widgets/timer/app_timer.dart';
|
||||
import 'package:test_sa/models/timer_model.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
class OneTimeTaskPreviewScreen extends StatelessWidget {
|
||||
static const String routeName = "/one_time_task_preview";
|
||||
|
||||
final OneTimeTaskData taskData;
|
||||
final double totalWorkingHours;
|
||||
final VoidCallback onConfirm;
|
||||
|
||||
const OneTimeTaskPreviewScreen({
|
||||
Key? key,
|
||||
required this.taskData,
|
||||
required this.totalWorkingHours,
|
||||
required this.onConfirm,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Debug logging
|
||||
print('DEBUG Preview Screen:');
|
||||
print('imageRooms count: ${taskData.imageRooms?.length ?? 0}');
|
||||
print('attachments count: ${taskData.attachments?.length ?? 0}');
|
||||
|
||||
// Determine which images to show: captured images (imageRooms) or API attachments
|
||||
final hasImageRooms = taskData.imageRooms != null && taskData.imageRooms!.isNotEmpty;
|
||||
final hasAttachments = taskData.attachments != null && taskData.attachments!.isNotEmpty;
|
||||
|
||||
return Scaffold(
|
||||
appBar: const DefaultAppBar(
|
||||
title: 'Preview',
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Task Information Card
|
||||
_buildTaskInfoCard(context),
|
||||
16.height,
|
||||
|
||||
// Working Hours Card
|
||||
if (totalWorkingHours > 0) ...[
|
||||
_buildWorkingHoursCard(context),
|
||||
16.height,
|
||||
],
|
||||
|
||||
// Checklist Card
|
||||
_buildChecklistCard(context),
|
||||
16.height,
|
||||
|
||||
// Captured Images Card
|
||||
if (hasImageRooms) ...[
|
||||
_buildImagesCard(context),
|
||||
16.height,
|
||||
],
|
||||
|
||||
// Attachments Card (from API)
|
||||
if (hasAttachments) ...[
|
||||
_buildAttachmentsCard(context),
|
||||
16.height,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Footer with Confirm Button
|
||||
FooterActionButton.footerContainer(
|
||||
context: context,
|
||||
child: AppFilledButton(
|
||||
label: context.translation.confirm,
|
||||
buttonColor: AppColor.primary10,
|
||||
onPressed: onConfirm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskInfoCard(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Task Information',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
12.height,
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (taskData.oneTimeSetting?.taskTypeName != null) _buildInfoRow(context, 'Task Type', taskData.oneTimeSetting!.taskTypeName!),
|
||||
if (taskData.siteName != null) _buildInfoRow(context, 'Site', taskData.siteName!),
|
||||
if (taskData.buildingName != null) _buildInfoRow(context, 'Building', taskData.buildingName!),
|
||||
if (taskData.floorName != null) _buildInfoRow(context, 'Floor', taskData.floorName!),
|
||||
if (taskData.departmentName != null) _buildInfoRow(context, 'Department', taskData.departmentName!),
|
||||
if (taskData.roomName != null) _buildInfoRow(context, 'Room', taskData.roomName!),
|
||||
if (taskData.locationDescription != null) _buildInfoRow(context, 'Location', taskData.locationDescription!),
|
||||
],
|
||||
).paddingAll(16),
|
||||
],
|
||||
).toShadowContainer(context, padding: 16);
|
||||
}
|
||||
|
||||
Widget _buildWorkingHoursCard(BuildContext context) {
|
||||
// Parse timer start time from taskData if available
|
||||
DateTime? timerStartTime;
|
||||
if (taskData.startTime != null) {
|
||||
try {
|
||||
timerStartTime = DateTime.parse(taskData.startTime!);
|
||||
} catch (e) {
|
||||
print('Error parsing start time: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Working Hours',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
12.height,
|
||||
if (timerStartTime != null)
|
||||
AppTimer(
|
||||
label: 'Working Time',
|
||||
timer: TimerModel(
|
||||
startAt: timerStartTime,
|
||||
endAt: null,
|
||||
durationInSecond: 0,
|
||||
),
|
||||
enabled: false, // Disable controls, just show the timer
|
||||
showTimer: true,
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDark ? AppColor.neutral20.withValues(alpha: 0.5) : AppColor.neutral10.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDark ? AppColor.neutral20.withValues(alpha: 0.5) : AppColor.neutral10.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Total Working Hours',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
CMRequestUtils.formatTotalWorkingHours(totalWorkingHours),
|
||||
style: AppTextStyles.heading6.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context, padding: 16);
|
||||
}
|
||||
|
||||
Widget _buildChecklistCard(BuildContext context) {
|
||||
final completedInstructions = taskData.instructions?.where((i) => i.isDone == true).toList() ?? [];
|
||||
final totalInstructions = taskData.instructions?.length ?? 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Checklist',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${completedInstructions.length}/$totalInstructions',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.height,
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: completedInstructions.length,
|
||||
separatorBuilder: (context, index) => const Divider(height: 1, color: AppColor.neutral30, thickness: 1).paddingOnly(top: 12, bottom: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final instruction = completedInstructions[index];
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColor.primary10,
|
||||
size: 20,
|
||||
),
|
||||
8.width,
|
||||
Expanded(
|
||||
child: Text(
|
||||
instruction.instructionText ?? '',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (instruction.comment != null && instruction.comment!.isNotEmpty) ...[
|
||||
8.height,
|
||||
Container(
|
||||
width: double.infinity, // Force full width
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDark ? AppColor.neutral20.withValues(alpha: 0.5) : AppColor.neutral10.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Comment:',
|
||||
style: AppTextStyles.tinyFont.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
4.height,
|
||||
Text(
|
||||
instruction.comment!,
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
).paddingAll(16),
|
||||
],
|
||||
).toShadowContainer(context, padding: 16);
|
||||
}
|
||||
|
||||
Widget _buildImagesCard(BuildContext context) {
|
||||
// Show captured images (base64 from camera)
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Captured Images',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
16.height,
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: taskData.imageRooms!.map((imageString) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// Show full screen image
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
child: _buildImage(imageString),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.isDark ? AppColor.neutral20 : AppColor.neutral30,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _buildImage(imageString),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context, padding: 16);
|
||||
}
|
||||
|
||||
Widget _buildAttachmentsCard(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Attachments',
|
||||
style: AppTextStyles.heading5.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
FilesList(
|
||||
images: taskData.attachments!.map((attachment) => URLs.getFileUrl(attachment.name ?? '') ?? '').toList(),
|
||||
showAsListView: true,
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context, padding: 16);
|
||||
}
|
||||
|
||||
Widget _buildImage(String imageString) {
|
||||
try {
|
||||
// imageString format: "filename|base64data"
|
||||
final parts = imageString.split('|');
|
||||
final base64Data = parts.length > 1 ? parts[1] : imageString;
|
||||
return Image.memory(
|
||||
base64Decode(base64Data),
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
} catch (e) {
|
||||
return const Icon(Icons.broken_image, size: 48);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(BuildContext context, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Text(
|
||||
'$label:',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value,
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,472 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:test_sa/controllers/api_routes/urls.dart';
|
||||
import 'package:test_sa/controllers/providers/api/user_provider.dart';
|
||||
import 'package:test_sa/dashboard_latest/dashboard_provider.dart';
|
||||
import 'package:test_sa/extensions/context_extension.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/extensions/string_extensions.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/models/one_time_task_data.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/provider/cleaner_provider.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/widgets/cleaner_checklist_widget.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/pages/one_time_task_preview_screen.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/app_lazy_loading.dart';
|
||||
import 'package:test_sa/new_views/common_widgets/default_app_bar.dart';
|
||||
import 'package:test_sa/new_views/pages/land_page/land_page.dart';
|
||||
import 'package:test_sa/views/widgets/loaders/app_loading.dart';
|
||||
import 'package:test_sa/views/widgets/loaders/no_data_found.dart';
|
||||
import 'package:test_sa/views/widgets/dialogs/confirm_dialog.dart';
|
||||
import 'package:test_sa/views/widgets/timer/app_timer.dart';
|
||||
import 'package:test_sa/models/timer_model.dart';
|
||||
import 'package:test_sa/views/widgets/requests/request_status.dart';
|
||||
|
||||
class OneTimeTaskView extends StatefulWidget {
|
||||
static const String routeName = "/one_time_task";
|
||||
final int taskId;
|
||||
final OneTimeTaskData? initialData;
|
||||
|
||||
const OneTimeTaskView({Key? key, required this.taskId, this.initialData}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<OneTimeTaskView> createState() => _OneTimeTaskViewState();
|
||||
}
|
||||
|
||||
class _OneTimeTaskViewState extends State<OneTimeTaskView> {
|
||||
OneTimeTaskData? _taskData;
|
||||
bool _isLoading = false;
|
||||
DateTime? _timerStart;
|
||||
List<ChecklistItem>? _checklistItems; // Cache checklist items
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use initial data if provided, otherwise load from API
|
||||
if (widget.initialData != null) {
|
||||
_taskData = widget.initialData;
|
||||
|
||||
// Initialize imageRooms as empty list if null
|
||||
_taskData?.imageRooms ??= [];
|
||||
|
||||
// Start timer automatically if task is in progress
|
||||
if (_taskData?.statusValue == 1 && _taskData?.startTime != null) {
|
||||
_timerStart = DateTime.parse(_taskData!.startTime!);
|
||||
}
|
||||
} else {
|
||||
_loadTaskData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTaskData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final result = await CleanerProvider().getOneTimeTaskById(id: widget.taskId);
|
||||
|
||||
if (result['success'] && result['data'] != null) {
|
||||
setState(() {
|
||||
_taskData = OneTimeTaskData.fromJson(result['data']);
|
||||
|
||||
// Initialize imageRooms as empty list if null
|
||||
_taskData?.imageRooms ??= [];
|
||||
|
||||
// Convert attachments from API to imageRooms format for display
|
||||
if ((_taskData?.imageRooms?.isEmpty ?? true) &&
|
||||
(_taskData?.attachments != null && _taskData!.attachments!.isNotEmpty)) {
|
||||
_taskData!.imageRooms = _taskData!.attachments!
|
||||
.map((attachment) => URLs.getFileUrl(attachment.name ?? '') ?? '')
|
||||
.where((url) => url.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
|
||||
// Start timer automatically if task is in progress
|
||||
if (_taskData?.statusValue == 1 && _taskData?.startTime != null) {
|
||||
_timerStart = DateTime.parse(_taskData!.startTime!);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, result['message'] ?? 'Failed to load task');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, 'Failed to load task data');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool get isClosed => _taskData?.statusValue == 2;
|
||||
|
||||
String _formatDuration(Duration duration) {
|
||||
if (duration.inDays > 0) {
|
||||
final hours = duration.inHours % 24;
|
||||
final minutes = duration.inMinutes % 60;
|
||||
return '${duration.inDays}d ${hours}h ${minutes}m';
|
||||
} else if (duration.inHours > 0) {
|
||||
final minutes = duration.inMinutes % 60;
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${duration.inHours}h ${minutes}m ${seconds}s';
|
||||
} else if (duration.inMinutes > 0) {
|
||||
final seconds = duration.inSeconds % 60;
|
||||
return '${duration.inMinutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${duration.inSeconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: DefaultAppBar(
|
||||
title: 'One Time Task'.addTranslation,
|
||||
onWillPopScope: null,
|
||||
onBackPress: (){
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
UserProvider userProvider = Provider.of<UserProvider>(context, listen: false);
|
||||
DashBoardProvider dashBoardProvider = Provider.of<DashBoardProvider>(context, listen: false);
|
||||
dashBoardProvider.getDashBoardCount(usersType:userProvider.user!.type!);
|
||||
dashBoardProvider.resetRequestDataList();
|
||||
dashBoardProvider.getRequestDetail(usersType: userProvider.user!.type!, status: dashBoardProvider.tabs[dashBoardProvider.currentListIndex].tag);
|
||||
});
|
||||
Navigator.pop(context);
|
||||
},
|
||||
),
|
||||
body: _isLoading
|
||||
? const ALoading()
|
||||
: _taskData != null
|
||||
? SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildTaskInfoCard(context),
|
||||
16.height,
|
||||
CleanerChecklistWidget(
|
||||
isClosed: isClosed,
|
||||
checklistItems: _convertInstructionsToChecklist(),
|
||||
onChecklistChanged: (value) {
|
||||
setState(() {
|
||||
_updateInstructions(value);
|
||||
});
|
||||
},
|
||||
capturedImages: _taskData!.imageRooms ?? [],
|
||||
onImagesChanged: (images) {
|
||||
setState(() {
|
||||
_taskData!.imageRooms = images;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isClosed) ...[
|
||||
FooterActionButton.footerContainer(
|
||||
context: context,
|
||||
child: AppFilledButton(
|
||||
label: context.translation.complete,
|
||||
buttonColor: AppColor.primary10,
|
||||
onPressed: _onComplete,
|
||||
),
|
||||
),
|
||||
]
|
||||
],
|
||||
),
|
||||
)
|
||||
: NoDataFound(message: context.translation.noDataFound).center,
|
||||
).handlePopScope(
|
||||
cxt: context,
|
||||
showPopUp: false,
|
||||
onSave: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskInfoCard(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (_taskData?.statusName != null)
|
||||
StatusLabel(
|
||||
radius: 4,
|
||||
label: _taskData!.statusName!,
|
||||
textColor: AppColor.getHistoryLogStatusTextColorByName(_taskData!.statusName!),
|
||||
backgroundColor: AppColor.getHistoryLogStatusColorByName(_taskData!.statusName!),
|
||||
),
|
||||
if (_taskData?.createdDate != null)
|
||||
Text(
|
||||
_taskData!.createdDate!.toString().toServiceRequestCardFormat,
|
||||
textAlign: TextAlign.end,
|
||||
style: AppTextStyles.tinyFont.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
12.height,
|
||||
if (_taskData?.oneTimeSetting?.taskTypeName != null)
|
||||
Text(
|
||||
_taskData!.oneTimeSetting!.taskTypeName!,
|
||||
style: AppTextStyles.heading4.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
8.height,
|
||||
if (_taskData?.oneTimeSetting?.instructionDescriptionName != null)
|
||||
Text(
|
||||
_taskData!.oneTimeSetting!.instructionDescriptionName!,
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
8.height,
|
||||
if (_taskData?.siteName != null) _buildInfoRow('Site', _taskData!.siteName!),
|
||||
if (_taskData?.buildingName != null) _buildInfoRow('Building', _taskData!.buildingName!),
|
||||
if (_taskData?.floorName != null) _buildInfoRow('Floor', _taskData!.floorName!),
|
||||
if (_taskData?.departmentName != null) _buildInfoRow('Department', _taskData!.departmentName!),
|
||||
if (_taskData?.roomName != null) _buildInfoRow('Room', _taskData!.roomName!),
|
||||
if (_taskData?.locationDescription != null) _buildInfoRow('Location', _taskData!.locationDescription!),
|
||||
// Timer display using AppTimer widget
|
||||
if (_timerStart != null && !isClosed) ...[
|
||||
12.height,
|
||||
AppTimer(
|
||||
label: 'Working Time',
|
||||
timer: TimerModel(
|
||||
startAt: _timerStart,
|
||||
endAt: null,
|
||||
durationInSecond: 0,
|
||||
),
|
||||
enabled: false, // Disable controls, just show the timer
|
||||
showTimer: true,
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDark ? AppColor.neutral20.withValues(alpha: 0.5) : AppColor.neutral10.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Display total duration if task is completed
|
||||
if (isClosed && _taskData?.startTime != null && _taskData?.endTime != null) ...[
|
||||
// 12.height,
|
||||
Text(
|
||||
'Total Duration: ${_formatDuration(DateTime.parse(_taskData!.endTime!).difference(DateTime.parse(_taskData!.startTime!)))}',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
).paddingOnly(start: 16, end: 16, top: 16, bottom: 16),
|
||||
],
|
||||
).toShadowContainer(context, padding: 0);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'$label: $value',
|
||||
style: AppTextStyles.bodyText.copyWith(
|
||||
color: context.isDark ? AppColor.neutral10 : AppColor.neutral120,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<ChecklistItem> _convertInstructionsToChecklist() {
|
||||
// If checklist items are already created, reuse them
|
||||
if (_checklistItems != null) {
|
||||
// Update the existing items with latest data from instructions
|
||||
for (var item in _checklistItems!) {
|
||||
final instruction = _taskData?.instructions?.firstWhere((i) => i.id == item.id, orElse: () => Instruction());
|
||||
if (instruction?.id != null) {
|
||||
item.isChecked = instruction!.isDone ?? false;
|
||||
// Only update comment if it's different to avoid cursor jumping
|
||||
if (item.comment != (instruction.comment ?? '')) {
|
||||
item.comment = instruction.comment ?? '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return _checklistItems!;
|
||||
}
|
||||
|
||||
// Create new checklist items only on first call
|
||||
_checklistItems = _taskData?.instructions?.map((instruction) {
|
||||
return ChecklistItem(
|
||||
id: instruction.id ?? 0,
|
||||
title: instruction.instructionText ?? '',
|
||||
isChecked: instruction.isDone ?? false,
|
||||
isMandatory: instruction.isRequired ?? false,
|
||||
comment: instruction.comment ?? '',
|
||||
);
|
||||
}).toList() ??
|
||||
[];
|
||||
|
||||
return _checklistItems!;
|
||||
}
|
||||
|
||||
void _updateInstructions(List<ChecklistItem> checklist) {
|
||||
_taskData?.instructions = checklist.map((item) {
|
||||
return Instruction(
|
||||
id: item.id,
|
||||
instructionTextId: _taskData?.instructions?.firstWhere((i) => i.id == item.id).instructionTextId,
|
||||
instructionText: item.title,
|
||||
isDone: item.isChecked,
|
||||
isRequired: item.isMandatory,
|
||||
comment: item.comment,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _onComplete() {
|
||||
// Validate mandatory checklist items
|
||||
final uncheckedMandatory = _taskData?.instructions?.where((item) => (item.isRequired ?? false) && !(item.isDone ?? false)).toList() ?? [];
|
||||
|
||||
if (uncheckedMandatory.isNotEmpty) {
|
||||
_showErrorDialog(context, "Please complete all mandatory tasks (marked with *)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that at least one image is captured
|
||||
final capturedImages = _taskData?.imageRooms ?? [];
|
||||
print('DEBUG: Captured images count: ${capturedImages.length}');
|
||||
print('DEBUG: Images: $capturedImages');
|
||||
|
||||
if (capturedImages.isEmpty) {
|
||||
_showErrorDialog(context, "Please capture at least one image before completing");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate working hours
|
||||
double totalWorkingHours = 0;
|
||||
if (_timerStart != null) {
|
||||
final duration = DateTime.now().difference(_timerStart!);
|
||||
totalWorkingHours = duration.inSeconds / 3600.0;
|
||||
}
|
||||
|
||||
// Navigate to preview screen
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OneTimeTaskPreviewScreen(
|
||||
taskData: _taskData!,
|
||||
totalWorkingHours: totalWorkingHours,
|
||||
onConfirm: () {
|
||||
Navigator.pop(context); // Close preview screen
|
||||
_performComplete(); // Proceed with completion
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _performComplete() async {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const AppLazyLoading(),
|
||||
);
|
||||
|
||||
try {
|
||||
final instructionsPayload = _taskData?.instructions?.map((instruction) {
|
||||
return {
|
||||
'id': instruction.id,
|
||||
'isDone': instruction.isDone,
|
||||
'comment': instruction.comment,
|
||||
};
|
||||
}).toList() ??
|
||||
[];
|
||||
|
||||
// Convert images to attachments format
|
||||
final attachmentsPayload = _taskData?.imageRooms?.map((imageString) {
|
||||
// imageString format: "filename|base64data"
|
||||
final parts = imageString.split('|');
|
||||
final originalName = parts.isNotEmpty ? parts[0] : 'image.jpg';
|
||||
final base64Data = parts.length > 1 ? parts[1] : imageString;
|
||||
|
||||
return {
|
||||
'id': 0,
|
||||
'name': "$originalName|$base64Data", // Send full formatted string
|
||||
'originalName': originalName,
|
||||
};
|
||||
}).toList() ??
|
||||
[];
|
||||
|
||||
final result = await CleanerProvider().completeOneTimeTask(
|
||||
id: _taskData!.id!,
|
||||
instructions: instructionsPayload,
|
||||
attachments: attachmentsPayload,
|
||||
);
|
||||
|
||||
if (mounted) Navigator.pop(context); // Close loading dialog
|
||||
|
||||
if (result['success']) {
|
||||
if (mounted) {
|
||||
// Navigate to dashboard
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
LandPage.routeName,
|
||||
(routes) => false,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, result['message'] ?? 'Failed to complete task');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) Navigator.pop(context);
|
||||
if (mounted) {
|
||||
_showErrorDialog(context, 'Failed to complete task. Please try again.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showErrorDialog(BuildContext context, String message) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
message: message,
|
||||
title: 'Error',
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Dispose all checklist item controllers
|
||||
if (_checklistItems != null) {
|
||||
for (var item in _checklistItems!) {
|
||||
item.dispose();
|
||||
}
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,393 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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';
|
||||
|
||||
class CleanerProvider extends ChangeNotifier {
|
||||
bool _isLoading = false;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// Search results
|
||||
List<dynamic>? _searchResults;
|
||||
List<dynamic>? get searchResults => _searchResults;
|
||||
|
||||
// State code for error handling
|
||||
int? _stateCode;
|
||||
int? get stateCode => _stateCode;
|
||||
|
||||
// TODO: Add your cleaner request model here
|
||||
// CleanerRequestModel? _currentCleanerRequest;
|
||||
// CleanerRequestModel? get currentCleanerRequest => _currentCleanerRequest;
|
||||
|
||||
Future<void> getCleanerRequestById({required int id}) async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// TODO: Implement API call to fetch cleaner request details
|
||||
// Example:
|
||||
// final response = await CleanerApiService.getCleanerRequestById(id);
|
||||
// _currentCleanerRequest = response.data;
|
||||
|
||||
await Future.delayed(const Duration(seconds: 1)); // Simulating API call
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('Error fetching cleaner request: $e');
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Search plan recurrent tasks
|
||||
Future<int> searchPlanRecurrentTasks({
|
||||
required int pageSize,
|
||||
required int pageNumber,
|
||||
int? siteId,
|
||||
int? buildingId,
|
||||
int? floorId,
|
||||
int? departmentId,
|
||||
int? roomId,
|
||||
}) async {
|
||||
if (_isLoading == true) return -2;
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.searchPlanRecurrentTaskListSearch,
|
||||
body: {
|
||||
"pageSize": pageSize,
|
||||
"pageNumber": pageNumber,
|
||||
"siteId": siteId,
|
||||
"buildingId": buildingId,
|
||||
"floorId": floorId,
|
||||
"departmentId": departmentId,
|
||||
"roomId": roomId,
|
||||
},
|
||||
);
|
||||
|
||||
_stateCode = response.statusCode;
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// client's request was successfully received
|
||||
final responseData = json.decode(response.body);
|
||||
_searchResults = responseData["data"] as List<dynamic>?;
|
||||
} else {
|
||||
_searchResults = null;
|
||||
}
|
||||
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return response.statusCode;
|
||||
} catch (error) {
|
||||
debugPrint('Error searching plan recurrent tasks: $error');
|
||||
_isLoading = false;
|
||||
_stateCode = -1;
|
||||
_searchResults = null;
|
||||
notifyListeners();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Search plan recurrent tasks and return results directly without provider state
|
||||
Future<Map<String, dynamic>> searchPlanRecurrentTasksList({
|
||||
required int pageSize,
|
||||
required int pageNumber,
|
||||
int? siteId,
|
||||
int? buildingId,
|
||||
int? floorId,
|
||||
int? departmentId,
|
||||
int? roomId,
|
||||
int? taskTypeId,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.searchPlanRecurrentTaskListSearch,
|
||||
body: {
|
||||
"pageSize": pageSize,
|
||||
"pageNumber": pageNumber,
|
||||
"siteId": siteId,
|
||||
"buildingId": buildingId,
|
||||
"floorId": floorId,
|
||||
"departmentId": departmentId,
|
||||
"roomId": roomId,
|
||||
"taskTypeId": taskTypeId,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
final responseData = json.decode(response.body);
|
||||
return {
|
||||
"data": responseData["data"] as List<dynamic>? ?? [],
|
||||
"totalRows": responseData["totalRows"] as int? ?? 0,
|
||||
};
|
||||
}
|
||||
return {"data": [], "totalRows": 0};
|
||||
} catch (error) {
|
||||
debugPrint('Error searching plan recurrent tasks: $error');
|
||||
return {"data": [], "totalRows": 0};
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if QR code matches the same room for a plan recurrent task
|
||||
Future<bool> checkQRSameRoom({
|
||||
required int planRecurrentTaskId,
|
||||
required String qrCode,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.checkQRSameRoom,
|
||||
body: {
|
||||
"planRecurrentTaskId": planRecurrentTaskId,
|
||||
"qrCode": qrCode,
|
||||
},
|
||||
showToast: false, // Disable automatic toast message
|
||||
);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
final responseData = json.decode(response.body);
|
||||
// Assuming the API returns a boolean in the data field or a success flag
|
||||
return responseData["data"] == true || responseData["isSuccess"] == true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
debugPrint('Error checking QR same room: $error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if QR code matches the same room for a plan recurrent task
|
||||
/// Returns a map with success status and message from backend
|
||||
Future<Map<String, dynamic>> checkQRSameRoomWithMessage({
|
||||
required int planRecurrentTaskId,
|
||||
required String qrCode,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.checkQRSameRoom,
|
||||
body: {
|
||||
"planRecurrentTaskId": planRecurrentTaskId,
|
||||
"qrCode": qrCode,
|
||||
},
|
||||
showToast: false, // Disable automatic toast message
|
||||
);
|
||||
|
||||
// Parse the response body to extract the message
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// Success response
|
||||
final isValid = responseData["data"] == true || responseData["isSuccess"] == true;
|
||||
return {
|
||||
"success": isValid,
|
||||
"message": responseData["message"] ?? (isValid ? "QR code validated successfully" : "QR code validation failed"),
|
||||
};
|
||||
} else {
|
||||
// Error response - extract the exact error message from backend
|
||||
return {
|
||||
"success": false,
|
||||
"message": responseData["message"] ?? "QR code validation failed",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error checking QR same room: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"message": "Failed to validate QR code. Please check your connection and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan QR Code and get task details
|
||||
/// Returns a map with success status, message, and data from backend
|
||||
Future<Map<String, dynamic>> scanQRCode({
|
||||
required String qrCode,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.scanQRCode,
|
||||
body: {
|
||||
"qrCode": qrCode,
|
||||
},
|
||||
showToast: false, // Disable automatic toast message
|
||||
);
|
||||
|
||||
// Parse the response body to extract the message
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// Success response
|
||||
return {
|
||||
"success": true,
|
||||
"data": responseData["data"],
|
||||
"message": responseData["message"] ?? "QR code scanned successfully",
|
||||
};
|
||||
} else {
|
||||
// Error response - extract the exact error message from backend
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": responseData["message"] ?? "QR code scan failed",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error scanning QR code: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Failed to scan QR code. Please check your connection and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Proceed with OneTime Task request
|
||||
/// Returns a map with success status, message, and data from backend
|
||||
Future<Map<String, dynamic>> proceedOneTimeTask({
|
||||
required int oneTimeSettingId,
|
||||
required int siteId,
|
||||
required int buildingId,
|
||||
required int floorId,
|
||||
required int departmentId,
|
||||
required int roomId,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.proceedRequest,
|
||||
body: {
|
||||
"oneTimeSettingId": oneTimeSettingId,
|
||||
"siteId": siteId,
|
||||
"buildingId": buildingId,
|
||||
"floorId": floorId,
|
||||
"departmentId": departmentId,
|
||||
"roomId": roomId,
|
||||
},
|
||||
showToast: false, // Disable automatic toast message
|
||||
);
|
||||
|
||||
// Parse the response body to extract the message
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// Success response
|
||||
return {
|
||||
"success": true,
|
||||
"data": responseData["data"],
|
||||
"message": responseData["message"] ?? "Request proceeded successfully",
|
||||
};
|
||||
} else {
|
||||
// Error response - extract the exact error message from backend
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": responseData["message"] ?? "Failed to proceed with request",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error proceeding with request: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Failed to proceed with request. Please check your connection and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Get OneTime Task by ID using getRequestById endpoint
|
||||
Future<Map<String, dynamic>> getOneTimeTaskById({
|
||||
required int id,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.get(
|
||||
'${URLs.getRequestById}?id=$id',
|
||||
);
|
||||
|
||||
// Parse the response body
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// Success response
|
||||
return {
|
||||
"success": true,
|
||||
"data": responseData["data"],
|
||||
"message": responseData["message"] ?? "Task retrieved successfully",
|
||||
};
|
||||
} else {
|
||||
// Error response
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": responseData["message"] ?? "Failed to retrieve task",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error getting one time task: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Failed to retrieve task. Please check your connection and try again.",
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete OneTime Task
|
||||
Future<Map<String, dynamic>> completeOneTimeTask({
|
||||
required int id,
|
||||
required List<Map<String, dynamic>> instructions,
|
||||
List<Map<String, dynamic>>? attachments,
|
||||
}) async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.post(
|
||||
URLs.completeRequest,
|
||||
body: {
|
||||
"id": id,
|
||||
"instructions": instructions,
|
||||
"attachments": attachments ?? [],
|
||||
},
|
||||
showToast: false,
|
||||
);
|
||||
|
||||
final responseData = json.decode(response.body);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return {
|
||||
"success": true,
|
||||
"data": responseData["data"],
|
||||
"message": responseData["message"] ?? "Task completed successfully",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": responseData["message"] ?? "Failed to complete task",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
debugPrint('Error completing one time task: $error');
|
||||
return {
|
||||
"success": false,
|
||||
"data": null,
|
||||
"message": "Failed to complete task. Please check your connection and try again.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_isLoading = false;
|
||||
_searchResults = null;
|
||||
_stateCode = null;
|
||||
// _currentCleanerRequest = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/cupertino.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/cleaner_module/models/task_type.dart';
|
||||
|
||||
class TaskTypeProvider extends ChangeNotifier {
|
||||
//reset provider data
|
||||
void reset() {
|
||||
_taskTypes = null;
|
||||
_stateCode = null;
|
||||
}
|
||||
|
||||
// state code of current request to define error message
|
||||
int? _stateCode;
|
||||
int? get stateCode => _stateCode;
|
||||
|
||||
// contain task types data
|
||||
List<TaskType>? _taskTypes;
|
||||
List<TaskType>? get taskTypes => _taskTypes;
|
||||
|
||||
// loading state
|
||||
bool _loading = false;
|
||||
bool get isLoading => _loading;
|
||||
|
||||
set isLoading(bool isLoading) {
|
||||
_loading = isLoading;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Get task types
|
||||
Future<int> getTaskTypes() async {
|
||||
if (_loading == true) return -2;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.get(URLs.getTaskType);
|
||||
_stateCode = response.statusCode;
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
// client's request was successfully received
|
||||
List taskTypesListJson = json.decode(response.body)["data"];
|
||||
_taskTypes = taskTypesListJson.map((taskType) => TaskType.fromJson(taskType)).toList();
|
||||
}
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
return response.statusCode;
|
||||
} catch (error) {
|
||||
_loading = false;
|
||||
_stateCode = -1;
|
||||
notifyListeners();
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get task types list directly without provider state
|
||||
Future<List<TaskType>> getTaskTypesList() async {
|
||||
Response response;
|
||||
try {
|
||||
response = await ApiManager.instance.get(URLs.getTaskType);
|
||||
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
List taskTypesListJson = json.decode(response.body)["data"];
|
||||
return taskTypesListJson.map((taskType) => TaskType.fromJson(taskType)).toList();
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,405 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.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';
|
||||
import 'package:test_sa/modules/cleaner_module/widgets/multi_image_picker.dart';
|
||||
import 'package:test_sa/views/widgets/images/files_list.dart';
|
||||
|
||||
class CleanerChecklistWidget extends StatefulWidget {
|
||||
final List<ChecklistItem> checklistItems;
|
||||
final Function(List<ChecklistItem>)? onChecklistChanged;
|
||||
final List<String> capturedImages;
|
||||
final Function(List<String>)? onImagesChanged;
|
||||
final bool isClosed;
|
||||
|
||||
const CleanerChecklistWidget({
|
||||
Key? key,
|
||||
required this.checklistItems,
|
||||
this.onChecklistChanged,
|
||||
required this.capturedImages,
|
||||
this.onImagesChanged,
|
||||
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,
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (cxt, index) => _buildChecklistItem(context, widget.checklistItems[index]),
|
||||
separatorBuilder: (cxt, index) => const Divider(height: 1, color: AppColor.neutral30, thickness: 1).paddingOnly(top: 16, bottom: 16),
|
||||
itemCount: widget.checklistItems.length)
|
||||
.toShadowContainer(context, padding: 16),
|
||||
16.height,
|
||||
_buildImageCaptureWidget(context),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChecklistItem(BuildContext context, ChecklistItem item) {
|
||||
return IgnorePointer(
|
||||
ignoring: widget.isClosed,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
AbsorbPointer(
|
||||
absorbing: true,
|
||||
child: Transform.scale(
|
||||
scale: 1.2,
|
||||
child: Checkbox(
|
||||
value: item.isChecked,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
side: BorderSide(color: Colors.black.withValues(alpha: .05)),
|
||||
visualDensity: const VisualDensity(horizontal: -4.0, vertical: -4.0),
|
||||
activeColor: AppColor.primary10,
|
||||
onChanged: (bool? value) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
10.width,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
).expanded,
|
||||
],
|
||||
).onPress(() {
|
||||
setState(() {
|
||||
item.isChecked = !item.isChecked;
|
||||
if (!item.isChecked) {
|
||||
item.commentController.clear();
|
||||
}
|
||||
});
|
||||
widget.onChecklistChanged?.call(widget.checklistItems);
|
||||
}),
|
||||
/* ListTileTheme(
|
||||
horizontalTitleGap: 0,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
minVerticalPadding: 0,
|
||||
child: Container(
|
||||
color: Colors.amberAccent,
|
||||
child: CheckboxListTile(
|
||||
value: item.isChecked,
|
||||
minVerticalPadding: 0,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
checkboxScaleFactor: 1.2,
|
||||
side: BorderSide(color: Colors.black.withValues(alpha: .05)),
|
||||
visualDensity: const VisualDensity(horizontal: -0.0, vertical: -4.0),
|
||||
activeColor: AppColor.primary10,
|
||||
title: 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
item.isChecked = value ?? false;
|
||||
if (!item.isChecked) {
|
||||
item.commentController.clear();
|
||||
}
|
||||
});
|
||||
widget.onChecklistChanged?.call(widget.checklistItems);
|
||||
},
|
||||
),
|
||||
),
|
||||
),*/
|
||||
if (item.isChecked) ...[
|
||||
12.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;
|
||||
widget.onChecklistChanged?.call(widget.checklistItems);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageCaptureWidget(BuildContext context) {
|
||||
// Base64 image display and capture widget
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Captured Image'.addTranslation,
|
||||
style: AppTextStyles.heading6.copyWith(
|
||||
color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
),
|
||||
),
|
||||
// Text(
|
||||
// '(${widget.capturedImages.length}/$maxImages)',
|
||||
// style: AppTextStyles.bodyText.copyWith(
|
||||
// color: context.isDark ? AppColor.neutral30 : AppColor.neutral50,
|
||||
// fontWeight: FontWeight.w600,
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
8.height,
|
||||
_buildImageWidget(context),
|
||||
],
|
||||
).toShadowContainer(context);
|
||||
}
|
||||
|
||||
static const int maxImages = 2;
|
||||
|
||||
/// Build the image widget with loading indicator for network images
|
||||
Widget _buildImageWidget(BuildContext context) {
|
||||
if (widget.capturedImages.isEmpty) {
|
||||
// Show add image button when no image
|
||||
return GestureDetector(
|
||||
onTap: widget.isClosed ? null : () async {
|
||||
// Only allow camera - pick one image at a time
|
||||
final pickedFile = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 70,
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
final base64Image = base64Encode(bytes);
|
||||
final fileName = pickedFile.path.split("/").last;
|
||||
final formattedImage = "$fileName|$base64Image";
|
||||
|
||||
// Add to existing images (max 2)
|
||||
final updatedImages = List<String>.from(widget.capturedImages);
|
||||
updatedImages.add(formattedImage);
|
||||
|
||||
widget.onImagesChanged?.call(updatedImages);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
height: 120,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.isDark ? AppColor.neutral20 : AppColor.neutral30,
|
||||
width: 2,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 32,
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
4.height,
|
||||
Text(
|
||||
'Add Image',
|
||||
style: AppTextStyles.tinyFont.copyWith(
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Check if images are URLs from API (like ServiceRequestDetailView does)
|
||||
final bool hasApiImages = widget.capturedImages.any((img) =>
|
||||
img.startsWith('http://') || img.startsWith('https://'));
|
||||
|
||||
// If images are from API (URLs), use FilesList widget like ServiceRequestDetailView
|
||||
if (hasApiImages && widget.isClosed) {
|
||||
return FilesList(
|
||||
images: widget.capturedImages,
|
||||
showAsListView: false,
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, use MultiImagePicker for base64 images or when editing
|
||||
return MultiImagePicker(
|
||||
imageUrls: widget.capturedImages,
|
||||
enabled: !widget.isClosed,
|
||||
maxImages: 2, // Enforce max 2 images
|
||||
onImageTap: (index, imageUrl) {
|
||||
// Handle image tap - show in full screen
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
InteractiveViewer(
|
||||
child: _buildImageForViewing(imageUrl),
|
||||
).center,
|
||||
const ABackButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onDelete: (index) {
|
||||
// Handle image delete
|
||||
if (index >= 0) {
|
||||
setState(() {
|
||||
widget.capturedImages.removeAt(index);
|
||||
});
|
||||
widget.onImagesChanged?.call(widget.capturedImages);
|
||||
}
|
||||
},
|
||||
onAdd: (imageUrl) {
|
||||
// Handle new image addition
|
||||
setState(() {
|
||||
widget.capturedImages.add(imageUrl);
|
||||
});
|
||||
widget.onImagesChanged?.call(widget.capturedImages);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build image widget for viewing based on source type
|
||||
Widget _buildImageForViewing(String imageUrl) {
|
||||
try {
|
||||
// Check if it's a URL from API
|
||||
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
|
||||
return Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(Icons.broken_image, size: 48, color: Colors.white);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, it's base64 encoded
|
||||
final parts = imageUrl.split('|');
|
||||
final base64Data = parts.length > 1 ? parts[1] : imageUrl;
|
||||
return Image.memory(
|
||||
base64Decode(base64Data),
|
||||
fit: BoxFit.contain,
|
||||
);
|
||||
} catch (e) {
|
||||
return const Icon(Icons.broken_image, size: 48, color: Colors.white);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Note: Don't dispose controllers here as they're managed by ChecklistItem
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:test_sa/extensions/context_extension.dart';
|
||||
import 'package:test_sa/extensions/int_extensions.dart';
|
||||
import 'package:test_sa/extensions/widget_extensions.dart';
|
||||
import 'package:test_sa/extensions/text_extensions.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/views/widgets/buttons/app_back_button.dart';
|
||||
import 'package:test_sa/modules/cm_module/cm_request_utils.dart';
|
||||
|
||||
class MultiImagePicker extends StatelessWidget {
|
||||
final List<String> imageUrls;
|
||||
final Function(int index, String imageUrl)? onImageTap;
|
||||
final Function(int index)? onDelete;
|
||||
final Function(String imageUrl)? onAdd;
|
||||
final bool enabled;
|
||||
final int maxImages;
|
||||
|
||||
const MultiImagePicker({
|
||||
Key? key,
|
||||
required this.imageUrls,
|
||||
this.onImageTap,
|
||||
this.onDelete,
|
||||
this.onAdd,
|
||||
this.enabled = true,
|
||||
this.maxImages = 2,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Display existing images horizontally with add button
|
||||
Row(
|
||||
children: [
|
||||
// Display existing images
|
||||
...List.generate(
|
||||
imageUrls.length,
|
||||
(index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: 8.toScreenWidth),
|
||||
child: _buildImageItem(context, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Add image button (only show if under max limit)
|
||||
if (enabled && imageUrls.length < maxImages)
|
||||
_buildAddImageButton(context),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImageItem(BuildContext context, int index) {
|
||||
final imageUrl = imageUrls[index];
|
||||
|
||||
return Stack(
|
||||
alignment: AlignmentDirectional.topEnd,
|
||||
children: [
|
||||
Container(
|
||||
height: 120,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _getImageWidget(imageUrl),
|
||||
),
|
||||
).onPress(() {
|
||||
if (onImageTap != null) {
|
||||
onImageTap!(index, imageUrl);
|
||||
} else {
|
||||
_showFullImage(context, imageUrl);
|
||||
}
|
||||
}),
|
||||
if (enabled)
|
||||
Container(
|
||||
height: 32,
|
||||
width: 32,
|
||||
margin: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: (context.isDark ? AppColor.neutral20 : AppColor.neutral40).withValues(alpha: 0.9),
|
||||
border: Border.all(
|
||||
color: (context.isDark ? AppColor.neutral20 : AppColor.neutral40).withValues(alpha: 0.7),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.close, color: Color(0xffF63939), size: 20),
|
||||
).onPress(() {
|
||||
if (onDelete != null) {
|
||||
onDelete!(index);
|
||||
}
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddImageButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => _pickImage(context),
|
||||
child: Container(
|
||||
height: 120,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.fieldBgColor(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: context.isDark ? AppColor.neutral20 : AppColor.neutral30,
|
||||
width: 2,
|
||||
style: BorderStyle.solid,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add_photo_alternate,
|
||||
size: 32,
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
4.height,
|
||||
Text(
|
||||
'Add Image',
|
||||
style: AppTextStyles.tinyFont.copyWith(
|
||||
color: context.isDark ? AppColor.white10 : AppColor.black10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getImageWidget(String imageUrl) {
|
||||
// Check if it's base64 encoded (contains | separator)
|
||||
if (imageUrl.contains('|')) {
|
||||
try {
|
||||
final base64Data = imageUrl.split('|').last;
|
||||
return Image.memory(
|
||||
base64Decode(base64Data),
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
} catch (e) {
|
||||
return const Icon(Icons.broken_image, size: 48);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a local file path
|
||||
if (CMRequestUtils.isLocalUrl(imageUrl)) {
|
||||
return Image.file(
|
||||
File(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise treat as network URL
|
||||
return Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(Icons.broken_image, size: 48);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _pickImage(BuildContext context) async {
|
||||
// Directly open camera - no bottom sheet
|
||||
final ImagePicker picker = ImagePicker();
|
||||
|
||||
final XFile? pickedFile = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 70,
|
||||
maxWidth: 800,
|
||||
maxHeight: 800,
|
||||
);
|
||||
|
||||
if (pickedFile != null) {
|
||||
final bytes = await pickedFile.readAsBytes();
|
||||
final base64Image = base64Encode(bytes);
|
||||
final fileName = pickedFile.path.split("/").last;
|
||||
final formattedImage = "$fileName|$base64Image";
|
||||
|
||||
// Call the onAdd callback to notify parent
|
||||
if (onAdd != null) {
|
||||
onAdd!(formattedImage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showFullImage(BuildContext context, String imageUrl) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: InteractiveViewer(
|
||||
child: _getImageWidget(imageUrl),
|
||||
),
|
||||
),
|
||||
const ABackButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,137 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.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/models/all_requests_and_count_model.dart';
|
||||
import 'package:test_sa/models/new_models/dashboard_detail.dart';
|
||||
import 'package:test_sa/modules/cleaner_module/pages/one_time_task_view.dart';
|
||||
import 'package:test_sa/new_views/app_style/app_color.dart';
|
||||
import 'package:test_sa/views/widgets/requests/request_status.dart';
|
||||
|
||||
class OneTimeTaskItemView extends StatelessWidget {
|
||||
final Data? requestData;
|
||||
final RequestsDetails? requestDetails;
|
||||
final bool showShadow;
|
||||
|
||||
const OneTimeTaskItemView({Key? key, this.requestData, this.requestDetails, this.showShadow = true}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (requestData != null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusLabel(
|
||||
label: requestData!.statusName ?? '',
|
||||
textColor: AppColor.getRequestStatusTextColorByName(context, requestData!.statusName ?? ''),
|
||||
backgroundColor: AppColor.getRequestStatusColorByName(context, requestData!.statusName ?? ''),
|
||||
),
|
||||
1.width.expanded,
|
||||
Text(
|
||||
requestData!.transactionDate?.toServiceRequestCardFormat ?? "",
|
||||
textAlign: TextAlign.end,
|
||||
style: AppTextStyles.tinyFont.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral50),
|
||||
),
|
||||
],
|
||||
),
|
||||
8.height,
|
||||
(requestData?.typeTransaction ?? context.translation.recurrentWo).heading5(context),
|
||||
8.height,
|
||||
'${context.translation.taskNo}: ${requestData!.requestNo}'.bodyText(context),
|
||||
if (requestData?.siteName != null) '${context.translation.site}: ${requestData!.siteName}'.bodyText(context),
|
||||
if (requestData!.buildingName != null) '${context.translation.building}: ${requestData!.buildingName}'.bodyText(context),
|
||||
if (requestData!.departmentName != null) '${context.translation.department}: ${requestData!.departmentName}'.bodyText(context),
|
||||
if (requestData!.floorName != null) '${context.translation.floor}: ${requestData!.floorName}'.bodyText(context),
|
||||
if (requestData!.roomName != null) '${context.translation.room}: ${requestData!.roomName}'.bodyText(context),
|
||||
8.height,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.translation.viewDetails,
|
||||
style: AppTextStyles.bodyText.copyWith(color: AppColor.blueStatus(context)),
|
||||
),
|
||||
4.width,
|
||||
Icon(Icons.arrow_forward, color: AppColor.blueStatus(context), size: 14)
|
||||
],
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context, withShadow: showShadow).onPress(() {
|
||||
// Navigate to editable view for in-progress tasks
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OneTimeTaskView(
|
||||
taskId: requestData!.id ?? 0,
|
||||
initialData: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
if (requestDetails != null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StatusLabel(
|
||||
label: requestDetails!.status!,
|
||||
textColor: AppColor.getRequestStatusTextColorByName(context, requestDetails!.status!),
|
||||
backgroundColor: AppColor.getRequestStatusColorByName(context, requestDetails!.status!),
|
||||
),
|
||||
1.width.expanded,
|
||||
Text(
|
||||
requestDetails!.date?.toServiceRequestCardFormat ?? "",
|
||||
textAlign: TextAlign.end,
|
||||
style: AppTextStyles.tinyFont.copyWith(color: context.isDark ? AppColor.neutral10 : AppColor.neutral50),
|
||||
),
|
||||
],
|
||||
),
|
||||
8.height,
|
||||
(requestDetails?.nameOfType ?? context.translation.recurrentWo).heading5(context),
|
||||
8.height,
|
||||
'${context.translation.site}: ${requestDetails!.site}'.bodyText(context),
|
||||
'${context.translation.taskNo}: ${requestDetails!.requestNo}'.bodyText(context),
|
||||
if (requestDetails!.siteName != null) '${context.translation.site}: ${requestDetails!.siteName}'.bodyText(context),
|
||||
if (requestDetails!.buildingName != null) '${context.translation.building}: ${requestDetails!.buildingName}'.bodyText(context),
|
||||
if (requestDetails!.departmentName != null) '${context.translation.department}: ${requestDetails!.departmentName}'.bodyText(context),
|
||||
if (requestDetails!.floorName != null) '${context.translation.floor}: ${requestDetails!.floorName}'.bodyText(context),
|
||||
if (requestDetails!.roomName != null) '${context.translation.room}: ${requestDetails!.roomName}'.bodyText(context),
|
||||
8.height,
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
context.translation.viewDetails,
|
||||
style: AppTextStyles.bodyText.copyWith(color: AppColor.blueStatus(context)),
|
||||
),
|
||||
4.width,
|
||||
Icon(Icons.arrow_forward, color: AppColor.blueStatus(context), size: 14)
|
||||
],
|
||||
),
|
||||
],
|
||||
).toShadowContainer(context, withShadow: showShadow).onPress(() async {
|
||||
// Navigate to editable view for in-progress tasks
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => OneTimeTaskView(
|
||||
taskId: requestDetails!.id ?? 0,
|
||||
initialData: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
|
||||
Loading…
Reference in New Issue