feat: Implement document editing functionality with permissions and attachment handling
This commit is contained in:
parent
bf84ef4786
commit
99bd26942c
@ -68,6 +68,10 @@ class DocumentUploadController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> fetchPresignedUrl(String versionId) async {
|
||||
return await ApiService.getPresignedUrlApi(versionId);
|
||||
}
|
||||
|
||||
/// Fetch available document tags
|
||||
Future<void> fetchTags() async {
|
||||
try {
|
||||
@ -188,4 +192,48 @@ class DocumentUploadController extends GetxController {
|
||||
isUploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> editDocument(Map<String, dynamic> payload) async {
|
||||
try {
|
||||
isUploading.value = true;
|
||||
|
||||
final attachment = payload["attachment"];
|
||||
|
||||
final success = await ApiService.editDocumentApi(
|
||||
id: payload["id"],
|
||||
name: payload["name"],
|
||||
documentId: payload["documentId"],
|
||||
description: payload["description"],
|
||||
tags: (payload["tags"] as List).cast<Map<String, dynamic>>(),
|
||||
attachment: attachment,
|
||||
);
|
||||
|
||||
if (success) {
|
||||
showAppSnackbar(
|
||||
title: "Success",
|
||||
message: "Document updated successfully",
|
||||
type: SnackbarType.success,
|
||||
);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to update document",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (e, stack) {
|
||||
logSafe("Edit error: $e", level: LogLevel.error);
|
||||
logSafe("Stacktrace: $stack", level: LogLevel.debug);
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "An unexpected error occurred",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
isUploading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -332,13 +332,8 @@ class ApiService {
|
||||
required String name,
|
||||
required String documentId,
|
||||
String? description,
|
||||
required String fileName,
|
||||
required String base64Data,
|
||||
required String contentType,
|
||||
required int fileSize,
|
||||
String? fileDescription,
|
||||
bool isActive = true,
|
||||
List<Map<String, dynamic>> tags = const [],
|
||||
Map<String, dynamic>? attachment, // 👈 can be null
|
||||
}) async {
|
||||
final endpoint = "${ApiEndpoints.editDocument}/$id";
|
||||
logSafe("Editing document with id: $id");
|
||||
@ -348,19 +343,12 @@ class ApiService {
|
||||
"name": name,
|
||||
"documentId": documentId,
|
||||
"description": description ?? "",
|
||||
"attachment": {
|
||||
"fileName": fileName,
|
||||
"base64Data": base64Data,
|
||||
"contentType": contentType,
|
||||
"fileSize": fileSize,
|
||||
"description": fileDescription ?? "",
|
||||
"isActive": isActive,
|
||||
},
|
||||
"tags": tags.isNotEmpty
|
||||
? tags
|
||||
: [
|
||||
{"name": "default", "isActive": true}
|
||||
],
|
||||
"attachment": attachment, // 👈 null or object
|
||||
};
|
||||
|
||||
try {
|
||||
|
@ -98,4 +98,23 @@ class Permissions {
|
||||
|
||||
/// Entity ID for employee documents
|
||||
static const String employeeEntity = "dbb9555a-7a0c-40f2-a9ed-f0463f1ceed7";
|
||||
|
||||
// ------------------- Document Permissions ----------------------------
|
||||
/// Permission to view documents
|
||||
static const String viewDocument = "71189504-f1c8-4ca5-8db6-810497be2854";
|
||||
|
||||
/// Permission to upload documents
|
||||
static const String uploadDocument = "3f6d1f67-6fa5-4b7c-b17b-018d4fe4aab8";
|
||||
|
||||
/// Permission to modify documents
|
||||
static const String modifyDocument = "c423fd81-6273-4b9d-bb5e-76a0fb343833";
|
||||
|
||||
/// Permission to delete documents
|
||||
static const String deleteDocument = "40863a13-5a66-469d-9b48-135bc5dbf486";
|
||||
|
||||
/// Permission to download documents
|
||||
static const String downloadDocument = "404373d0-860f-490e-a575-1c086ffbce1d";
|
||||
|
||||
/// Permission to verify documents
|
||||
static const String verifyDocument = "13a1f30f-38d1-41bf-8e7a-b75189aab8e0";
|
||||
}
|
||||
|
@ -130,19 +130,30 @@ class UploadedBy {
|
||||
jobRoleName: json['jobRoleName'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'firstName': firstName,
|
||||
'lastName': lastName,
|
||||
'photo': photo,
|
||||
'jobRoleId': jobRoleId,
|
||||
'jobRoleName': jobRoleName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentType {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? regexExpression; // nullable
|
||||
final String? regexExpression;
|
||||
final String allowedContentType;
|
||||
final int maxSizeAllowedInMB;
|
||||
final bool isValidationRequired;
|
||||
final bool isMandatory;
|
||||
final bool isSystem;
|
||||
final bool isActive;
|
||||
final DocumentCategory? documentCategory; // nullable
|
||||
final DocumentCategory? documentCategory;
|
||||
|
||||
DocumentType({
|
||||
required this.id,
|
||||
@ -173,6 +184,21 @@ class DocumentType {
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'regexExpression': regexExpression,
|
||||
'allowedContentType': allowedContentType,
|
||||
'maxSizeAllowedInMB': maxSizeAllowedInMB,
|
||||
'isValidationRequired': isValidationRequired,
|
||||
'isMandatory': isMandatory,
|
||||
'isSystem': isSystem,
|
||||
'isActive': isActive,
|
||||
'documentCategory': documentCategory?.toJson(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentCategory {
|
||||
@ -196,6 +222,15 @@ class DocumentCategory {
|
||||
entityTypeId: json['entityTypeId'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'entityTypeId': entityTypeId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentTag {
|
||||
@ -210,4 +245,11 @@ class DocumentTag {
|
||||
isActive: json['isActive'] ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'isActive': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
753
lib/model/document/document_edit_bottom_sheet.dart
Normal file
753
lib/model/document/document_edit_bottom_sheet.dart
Normal file
@ -0,0 +1,753 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/document/document_upload_controller.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/model/document/master_document_type_model.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class DocumentEditBottomSheet extends StatefulWidget {
|
||||
final Map<String, dynamic> documentData;
|
||||
final Function(Map<String, dynamic>) onSubmit;
|
||||
|
||||
const DocumentEditBottomSheet({
|
||||
Key? key,
|
||||
required this.documentData,
|
||||
required this.onSubmit,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<DocumentEditBottomSheet> createState() =>
|
||||
_DocumentEditBottomSheetState();
|
||||
}
|
||||
|
||||
class _DocumentEditBottomSheetState extends State<DocumentEditBottomSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final controller = Get.put(DocumentUploadController());
|
||||
|
||||
final TextEditingController _docIdController = TextEditingController();
|
||||
final TextEditingController _docNameController = TextEditingController();
|
||||
final TextEditingController _descriptionController = TextEditingController();
|
||||
String? latestVersionUrl;
|
||||
|
||||
File? selectedFile;
|
||||
bool fileChanged = false;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_docIdController.text = widget.documentData["documentId"] ?? "";
|
||||
_docNameController.text = widget.documentData["name"] ?? "";
|
||||
_descriptionController.text = widget.documentData["description"] ?? "";
|
||||
|
||||
// Tags
|
||||
if (widget.documentData["tags"] != null) {
|
||||
controller.enteredTags.assignAll(
|
||||
List<String>.from(
|
||||
(widget.documentData["tags"] as List)
|
||||
.map((t) => t is String ? t : t["name"]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Convert category map to DocumentType ---
|
||||
if (widget.documentData["category"] != null) {
|
||||
controller.selectedCategory =
|
||||
DocumentType.fromJson(widget.documentData["category"]);
|
||||
}
|
||||
|
||||
// Type (if separate)
|
||||
if (widget.documentData["type"] != null) {
|
||||
controller.selectedType =
|
||||
DocumentType.fromJson(widget.documentData["type"]);
|
||||
}
|
||||
// Fetch latest version URL if attachment exists
|
||||
final latestVersion = widget.documentData["attachment"];
|
||||
if (latestVersion != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final url = await controller.fetchPresignedUrl(latestVersion["id"]);
|
||||
if (url != null) {
|
||||
setState(() {
|
||||
latestVersionUrl = url;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_docIdController.dispose();
|
||||
_docNameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSubmit() async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
// ✅ Validate only if user picked a new file
|
||||
if (fileChanged && selectedFile != null) {
|
||||
final maxSizeMB = controller.selectedType?.maxSizeAllowedInMB;
|
||||
if (maxSizeMB != null && controller.selectedFileSize != null) {
|
||||
final fileSizeMB = controller.selectedFileSize! / (1024 * 1024);
|
||||
if (fileSizeMB > maxSizeMB) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "File size exceeds $maxSizeMB MB limit",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final payload = {
|
||||
"id": widget.documentData["id"],
|
||||
"documentId": _docIdController.text.trim(),
|
||||
"name": _docNameController.text.trim(),
|
||||
"description": _descriptionController.text.trim(),
|
||||
"documentTypeId": controller.selectedType?.id,
|
||||
"tags": controller.enteredTags
|
||||
.map((t) => {"name": t, "isActive": true})
|
||||
.toList(),
|
||||
};
|
||||
|
||||
// ✅ Always include attachment logic
|
||||
if (fileChanged) {
|
||||
if (selectedFile != null) {
|
||||
// User picked new file
|
||||
payload["attachment"] = {
|
||||
"fileName": controller.selectedFileName,
|
||||
"base64Data": controller.selectedFileBase64,
|
||||
"contentType": controller.selectedFileContentType,
|
||||
"fileSize": controller.selectedFileSize,
|
||||
"isActive": true,
|
||||
};
|
||||
} else {
|
||||
// User explicitly removed file
|
||||
payload["attachment"] = null;
|
||||
}
|
||||
} else {
|
||||
// ✅ User did NOT touch the attachment → send null explicitly
|
||||
payload["attachment"] = null;
|
||||
}
|
||||
|
||||
// else: do nothing → existing attachment remains as is
|
||||
|
||||
final success = await controller.editDocument(payload);
|
||||
if (success) {
|
||||
widget.onSubmit(payload);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf', 'jpg', 'png', 'jpeg'],
|
||||
);
|
||||
|
||||
if (result != null && result.files.single.path != null) {
|
||||
final file = File(result.files.single.path!);
|
||||
final fileName = result.files.single.name;
|
||||
final fileBytes = await file.readAsBytes();
|
||||
final base64Data = base64Encode(fileBytes);
|
||||
|
||||
setState(() {
|
||||
selectedFile = file;
|
||||
fileChanged = true;
|
||||
controller.selectedFileName = fileName;
|
||||
controller.selectedFileBase64 = base64Data;
|
||||
controller.selectedFileContentType =
|
||||
result.files.single.extension?.toLowerCase() == "pdf"
|
||||
? "application/pdf"
|
||||
: "image/${result.files.single.extension?.toLowerCase()}";
|
||||
controller.selectedFileSize = (fileBytes.length / 1024).round();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: "Edit Document",
|
||||
onCancel: () => Navigator.pop(context),
|
||||
onSubmit: _handleSubmit,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document ID
|
||||
LabeledInput(
|
||||
label: "Document ID",
|
||||
hint: "Enter Document ID",
|
||||
controller: _docIdController,
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Name
|
||||
LabeledInput(
|
||||
label: "Document Name",
|
||||
hint: "e.g., PAN Card",
|
||||
controller: _docNameController,
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Category (Read-only, non-editable)
|
||||
LabeledInput(
|
||||
label: "Document Category",
|
||||
hint: "",
|
||||
controller: TextEditingController(
|
||||
text: controller.selectedCategory?.name ?? ""),
|
||||
validator: (_) => null,
|
||||
isRequired: false,
|
||||
// Disable interaction
|
||||
readOnly: true,
|
||||
),
|
||||
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Document Type (Read-only, non-editable)
|
||||
LabeledInput(
|
||||
label: "Document Type",
|
||||
hint: "",
|
||||
controller: TextEditingController(
|
||||
text: controller.selectedType?.name ?? ""),
|
||||
validator: (_) => null,
|
||||
isRequired: false,
|
||||
readOnly: true,
|
||||
),
|
||||
|
||||
MySpacing.height(24),
|
||||
|
||||
/// Attachment Section
|
||||
AttachmentSectionSingle(
|
||||
attachmentFile: selectedFile,
|
||||
attachmentUrl: latestVersionUrl,
|
||||
onPick: _pickFile,
|
||||
onRemove: () => setState(() {
|
||||
selectedFile = null;
|
||||
fileChanged = true;
|
||||
controller.selectedFileName = null;
|
||||
controller.selectedFileBase64 = null;
|
||||
controller.selectedFileContentType = null;
|
||||
controller.selectedFileSize = null;
|
||||
latestVersionUrl = null;
|
||||
}),
|
||||
),
|
||||
|
||||
if (controller.selectedType?.maxSizeAllowedInMB != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
"Max file size: ${controller.selectedType!.maxSizeAllowedInMB} MB",
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Tags Section
|
||||
MyText.labelMedium("Tags"),
|
||||
MySpacing.height(8),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: TextFormField(
|
||||
controller: controller.tagCtrl,
|
||||
onChanged: controller.filterSuggestions,
|
||||
onFieldSubmitted: (v) {
|
||||
controller.addEnteredTag(v);
|
||||
controller.tagCtrl.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: "Start typing to add tags",
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: _inputBorder(),
|
||||
enabledBorder: _inputBorder(),
|
||||
focusedBorder: _inputFocusedBorder(),
|
||||
contentPadding: MySpacing.all(16),
|
||||
),
|
||||
),
|
||||
),
|
||||
Obx(() => controller.filteredSuggestions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
margin: const EdgeInsets.only(top: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black12, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.filteredSuggestions.length,
|
||||
itemBuilder: (_, i) {
|
||||
final suggestion =
|
||||
controller.filteredSuggestions[i];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: Text(suggestion),
|
||||
onTap: () {
|
||||
controller.addEnteredTag(suggestion);
|
||||
controller.tagCtrl.clear();
|
||||
controller.clearSuggestions();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
MySpacing.height(8),
|
||||
Obx(() => Wrap(
|
||||
spacing: 8,
|
||||
children: controller.enteredTags
|
||||
.map((tag) => Chip(
|
||||
label: Text(tag),
|
||||
onDeleted: () =>
|
||||
controller.removeEnteredTag(tag),
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
|
||||
/// Description
|
||||
LabeledInput(
|
||||
label: "Description",
|
||||
hint: "Enter short description",
|
||||
controller: _descriptionController,
|
||||
validator: (v) =>
|
||||
v == null || v.trim().isEmpty ? "Required" : null,
|
||||
isRequired: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
OutlineInputBorder _inputBorder() => OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
);
|
||||
|
||||
OutlineInputBorder _inputFocusedBorder() => const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
);
|
||||
|
||||
/// ---------------- Single Attachment Widget (Rewritten) ----------------
|
||||
class AttachmentSectionSingle extends StatelessWidget {
|
||||
final File? attachmentFile; // Local file
|
||||
final String? attachmentUrl; // Online latest version URL
|
||||
final VoidCallback onPick;
|
||||
final VoidCallback? onRemove;
|
||||
|
||||
const AttachmentSectionSingle({
|
||||
Key? key,
|
||||
this.attachmentFile,
|
||||
this.attachmentUrl,
|
||||
required this.onPick,
|
||||
this.onRemove,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
|
||||
|
||||
Widget buildTile({File? file, String? url}) {
|
||||
final isImage = file != null
|
||||
? allowedImageExtensions
|
||||
.contains(file.path.split('.').last.toLowerCase())
|
||||
: url != null
|
||||
? allowedImageExtensions
|
||||
.contains(url.split('.').last.toLowerCase())
|
||||
: false;
|
||||
|
||||
final fileName = file != null
|
||||
? file.path.split('/').last
|
||||
: url != null
|
||||
? url.split('/').last
|
||||
: '';
|
||||
|
||||
IconData fileIcon = Icons.insert_drive_file;
|
||||
Color iconColor = Colors.blueGrey;
|
||||
|
||||
if (!isImage) {
|
||||
final ext = fileName.split('.').last.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
fileIcon = Icons.picture_as_pdf;
|
||||
iconColor = Colors.redAccent;
|
||||
break;
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
fileIcon = Icons.description;
|
||||
iconColor = Colors.blueAccent;
|
||||
break;
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
fileIcon = Icons.table_chart;
|
||||
iconColor = Colors.green;
|
||||
break;
|
||||
case 'txt':
|
||||
fileIcon = Icons.article;
|
||||
iconColor = Colors.grey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
if (isImage && file != null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => ImageViewerDialog(
|
||||
imageSources: [file],
|
||||
initialIndex: 0,
|
||||
),
|
||||
);
|
||||
} else if (url != null) {
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Could not open document",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: isImage && file != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(file, fit: BoxFit.cover),
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(fileIcon, color: iconColor, size: 30),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRemove != null)
|
||||
Positioned(
|
||||
top: -6,
|
||||
right: -6,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.red, size: 18),
|
||||
onPressed: onRemove,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min, // prevent overflow
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
if (attachmentFile != null)
|
||||
buildTile(file: attachmentFile)
|
||||
else if (attachmentUrl != null)
|
||||
buildTile(url: attachmentUrl)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: onPick,
|
||||
child: Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Colors.grey.shade100,
|
||||
),
|
||||
child: const Icon(Icons.add, size: 40, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Reusable Widgets ----
|
||||
|
||||
class LabeledInput extends StatelessWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextEditingController controller;
|
||||
final String? Function(String?) validator;
|
||||
final bool isRequired;
|
||||
final bool readOnly; // <-- Add this
|
||||
|
||||
const LabeledInput({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.controller,
|
||||
required this.validator,
|
||||
this.isRequired = false,
|
||||
this.readOnly = false, // default false
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
if (isRequired)
|
||||
const Text(
|
||||
" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
TextFormField(
|
||||
controller: controller,
|
||||
validator: validator,
|
||||
readOnly: readOnly, // <-- Use the new property here
|
||||
decoration: _inputDecoration(context, hint),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
InputDecoration _inputDecoration(BuildContext context, String hint) =>
|
||||
InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
contentPadding: MySpacing.all(16),
|
||||
);
|
||||
}
|
||||
|
||||
class LabeledDropdown extends StatefulWidget {
|
||||
final String label;
|
||||
final String hint;
|
||||
final String? value;
|
||||
final List<String> items;
|
||||
final ValueChanged<String> onChanged;
|
||||
final bool isRequired;
|
||||
|
||||
const LabeledDropdown({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<LabeledDropdown> createState() => _LabeledDropdownState();
|
||||
}
|
||||
|
||||
class _LabeledDropdownState extends State<LabeledDropdown> {
|
||||
final GlobalKey _dropdownKey = GlobalKey();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MyText.labelMedium(widget.label),
|
||||
if (widget.isRequired)
|
||||
const Text(
|
||||
" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
key: _dropdownKey,
|
||||
onTap: () async {
|
||||
final RenderBox renderBox =
|
||||
_dropdownKey.currentContext!.findRenderObject() as RenderBox;
|
||||
final Offset offset = renderBox.localToGlobal(Offset.zero);
|
||||
final Size size = renderBox.size;
|
||||
final RelativeRect position = RelativeRect.fromLTRB(
|
||||
offset.dx,
|
||||
offset.dy + size.height,
|
||||
offset.dx + size.width,
|
||||
offset.dy,
|
||||
);
|
||||
final selected = await showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: widget.items
|
||||
.map((item) => PopupMenuItem<String>(
|
||||
value: item,
|
||||
child: Text(item),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
if (selected != null) widget.onChanged(selected);
|
||||
},
|
||||
child: AbsorbPointer(
|
||||
child: TextFormField(
|
||||
readOnly: true,
|
||||
controller: TextEditingController(text: widget.value ?? ""),
|
||||
validator: (value) =>
|
||||
widget.isRequired && (value == null || value.isEmpty)
|
||||
? "Required"
|
||||
: null,
|
||||
decoration: _inputDecoration(context, widget.hint).copyWith(
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
InputDecoration _inputDecoration(BuildContext context, String hint) =>
|
||||
InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(12)),
|
||||
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||
),
|
||||
contentPadding: MySpacing.all(16),
|
||||
);
|
||||
}
|
||||
|
||||
class FilePickerTile extends StatelessWidget {
|
||||
final String? pickedFile;
|
||||
final VoidCallback onTap;
|
||||
final bool isRequired;
|
||||
|
||||
const FilePickerTile({
|
||||
Key? key,
|
||||
required this.pickedFile,
|
||||
required this.onTap,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
MyText.labelMedium("Attachments"),
|
||||
if (isRequired)
|
||||
const Text(
|
||||
" *",
|
||||
style:
|
||||
TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: MySpacing.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.upload_file, color: Colors.blueAccent),
|
||||
const SizedBox(width: 12),
|
||||
Text(pickedFile ?? "Choose File"),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
@ -10,6 +10,9 @@ import 'package:marco/model/document/document_details_model.dart';
|
||||
import 'package:marco/controller/document/document_details_controller.dart';
|
||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/model/document/document_edit_bottom_sheet.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
import 'package:marco/helpers/utils/permission_constants.dart';
|
||||
|
||||
class DocumentDetailsPage extends StatefulWidget {
|
||||
final String documentId;
|
||||
@ -23,7 +26,8 @@ class DocumentDetailsPage extends StatefulWidget {
|
||||
class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
final DocumentDetailsController controller =
|
||||
Get.put(DocumentDetailsController());
|
||||
|
||||
final PermissionController permissionController =
|
||||
Get.find<PermissionController>();
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -72,8 +76,7 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
return MyRefreshIndicator(
|
||||
onRefresh: _onRefresh,
|
||||
child: SingleChildScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -148,12 +151,58 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.red),
|
||||
onPressed: () {
|
||||
|
||||
},
|
||||
),
|
||||
if (permissionController
|
||||
.hasPermission(Permissions.modifyDocument))
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit, color: Colors.red),
|
||||
onPressed: () async {
|
||||
// existing bottom sheet flow
|
||||
await controller
|
||||
.fetchDocumentVersions(doc.parentAttachmentId);
|
||||
|
||||
final latestVersion = controller.versions.isNotEmpty
|
||||
? controller.versions.reduce((a, b) =>
|
||||
a.uploadedAt.isAfter(b.uploadedAt) ? a : b)
|
||||
: null;
|
||||
|
||||
final documentData = {
|
||||
"id": doc.id,
|
||||
"documentId": doc.documentId,
|
||||
"name": doc.name,
|
||||
"description": doc.description,
|
||||
"tags": doc.tags
|
||||
.map((t) => {"name": t.name, "isActive": t.isActive})
|
||||
.toList(),
|
||||
"category": doc.documentType.documentCategory?.toJson(),
|
||||
"type": doc.documentType.toJson(),
|
||||
"attachment": latestVersion != null
|
||||
? {
|
||||
"id": latestVersion.id,
|
||||
"fileName": latestVersion.name,
|
||||
"contentType": latestVersion.contentType,
|
||||
"fileSize": latestVersion.fileSize,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) {
|
||||
return DocumentEditBottomSheet(
|
||||
documentData: documentData,
|
||||
onSubmit: (updatedData) async {
|
||||
await _fetchDetails();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
MySpacing.height(12),
|
||||
@ -220,21 +269,25 @@ class _DocumentDetailsPageState extends State<DocumentDetailsPage> {
|
||||
"Uploaded by ${version.uploadedBy.firstName} ${version.uploadedBy.lastName} • $uploadDate",
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
||||
onPressed: () async {
|
||||
final url = await controller.fetchPresignedUrl(version.id);
|
||||
if (url != null) {
|
||||
_openDocument(url);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch document link",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
trailing:
|
||||
permissionController.hasPermission(Permissions.viewDocument)
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.open_in_new, color: Colors.blue),
|
||||
onPressed: () async {
|
||||
final url =
|
||||
await controller.fetchPresignedUrl(version.id);
|
||||
if (url != null) {
|
||||
_openDocument(url);
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch document link",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -16,6 +16,7 @@ import 'package:marco/view/document/document_details_page.dart';
|
||||
import 'package:marco/helpers/widgets/custom_app_bar.dart';
|
||||
import 'package:marco/helpers/widgets/my_confirmation_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/controller/permission_controller.dart';
|
||||
|
||||
class UserDocumentsPage extends StatefulWidget {
|
||||
final String? entityId;
|
||||
@ -33,6 +34,8 @@ class UserDocumentsPage extends StatefulWidget {
|
||||
|
||||
class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
final DocumentController docController = Get.put(DocumentController());
|
||||
final PermissionController permissionController =
|
||||
Get.find<PermissionController>();
|
||||
|
||||
String get entityTypeId => widget.isEmployee
|
||||
? Permissions.employeeEntity
|
||||
@ -143,6 +146,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
icon: const Icon(Icons.more_vert, color: Colors.black54),
|
||||
onSelected: (value) async {
|
||||
if (value == "delete") {
|
||||
// existing delete flow (unchanged)
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (_) => ConfirmDialog(
|
||||
@ -184,6 +188,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
debugPrint("✅ Document deleted and removed from list");
|
||||
}
|
||||
} else if (value == "activate") {
|
||||
// existing activate flow (unchanged)
|
||||
final success = await docController.toggleDocumentActive(
|
||||
doc.id,
|
||||
isActive: true,
|
||||
@ -207,12 +212,16 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
if (doc.isActive)
|
||||
if (doc.isActive &&
|
||||
permissionController
|
||||
.hasPermission(Permissions.deleteDocument))
|
||||
const PopupMenuItem(
|
||||
value: "delete",
|
||||
child: Text("Delete"),
|
||||
)
|
||||
else
|
||||
else if (!doc.isActive &&
|
||||
permissionController
|
||||
.hasPermission(Permissions.modifyDocument))
|
||||
const PopupMenuItem(
|
||||
value: "activate",
|
||||
child: Text("Activate"),
|
||||
@ -322,7 +331,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(),
|
||||
icon: Icon(
|
||||
Icons.tune,
|
||||
@ -455,6 +464,29 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
// 🔒 Check for viewDocument permission
|
||||
if (!permissionController.hasPermission(Permissions.viewDocument)) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.lock_outline, size: 60, color: Colors.grey),
|
||||
MySpacing.height(18),
|
||||
MyText.titleMedium(
|
||||
'Access Denied',
|
||||
fontWeight: 600,
|
||||
color: Colors.grey,
|
||||
),
|
||||
MySpacing.height(10),
|
||||
MyText.bodySmall(
|
||||
'You do not have permission to view documents.',
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Obx(() {
|
||||
if (docController.isLoading.value && docController.documents.isEmpty) {
|
||||
return SingleChildScrollView(
|
||||
@ -468,10 +500,7 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
_buildFilterRow(context),
|
||||
|
||||
// 👇 Add this
|
||||
_buildStatusHeader(),
|
||||
|
||||
Expanded(
|
||||
child: MyRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
@ -523,7 +552,6 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Conditionally show AppBar (example: hide if employee view)
|
||||
final bool showAppBar = !widget.isEmployee;
|
||||
|
||||
return Scaffold(
|
||||
@ -537,47 +565,51 @@ class _UserDocumentsPageState extends State<UserDocumentsPage> {
|
||||
)
|
||||
: null,
|
||||
body: _buildBody(context),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
final uploadController = Get.put(DocumentUploadController());
|
||||
floatingActionButton: permissionController
|
||||
.hasPermission(Permissions.uploadDocument)
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () {
|
||||
final uploadController = Get.put(DocumentUploadController());
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => DocumentUploadBottomSheet(
|
||||
onSubmit: (data) async {
|
||||
final success = await uploadController.uploadDocument(
|
||||
name: data["name"],
|
||||
description: data["description"],
|
||||
documentId: data["documentId"],
|
||||
entityId: resolvedEntityId,
|
||||
documentTypeId: data["documentTypeId"],
|
||||
fileName: data["attachment"]["fileName"],
|
||||
base64Data: data["attachment"]["base64Data"],
|
||||
contentType: data["attachment"]["contentType"],
|
||||
fileSize: data["attachment"]["fileSize"],
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => DocumentUploadBottomSheet(
|
||||
onSubmit: (data) async {
|
||||
final success = await uploadController.uploadDocument(
|
||||
name: data["name"],
|
||||
description: data["description"],
|
||||
documentId: data["documentId"],
|
||||
entityId: resolvedEntityId,
|
||||
documentTypeId: data["documentTypeId"],
|
||||
fileName: data["attachment"]["fileName"],
|
||||
base64Data: data["attachment"]["base64Data"],
|
||||
contentType: data["attachment"]["contentType"],
|
||||
fileSize: data["attachment"]["fileSize"],
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context);
|
||||
docController.fetchDocuments(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: resolvedEntityId,
|
||||
reset: true,
|
||||
);
|
||||
} else {
|
||||
Get.snackbar(
|
||||
"Error", "Upload failed, please try again");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.pop(context);
|
||||
docController.fetchDocuments(
|
||||
entityTypeId: entityTypeId,
|
||||
entityId: resolvedEntityId,
|
||||
reset: true,
|
||||
);
|
||||
} else {
|
||||
Get.snackbar("Error", "Upload failed, please try again");
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: MyText.bodyMedium("Add Document",
|
||||
color: Colors.white, fontWeight: 600),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
icon: const Icon(Icons.add, color: Colors.white),
|
||||
label: MyText.bodyMedium("Add Document",
|
||||
color: Colors.white, fontWeight: 600),
|
||||
backgroundColor: Colors.red,
|
||||
)
|
||||
: null,
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
|
||||
);
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user