marco.pms.mobileapp/lib/model/document/document_upload_bottom_sheet.dart

701 lines
23 KiB
Dart

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_text.dart';
import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
class DocumentUploadBottomSheet extends StatefulWidget {
final Function(Map<String, dynamic>) onSubmit;
const DocumentUploadBottomSheet({
Key? key,
required this.onSubmit,
}) : super(key: key);
@override
State<DocumentUploadBottomSheet> createState() =>
_DocumentUploadBottomSheetState();
}
class _DocumentUploadBottomSheetState extends State<DocumentUploadBottomSheet> {
final _formKey = GlobalKey<FormState>();
final controller = Get.put(DocumentUploadController());
final TextEditingController _docIdController = TextEditingController();
final TextEditingController _docNameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
File? selectedFile;
@override
void dispose() {
_docIdController.dispose();
_docNameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _handleSubmit() {
if (!(_formKey.currentState?.validate() ?? false)) return;
if (selectedFile == null) {
showAppSnackbar(
title: "Error",
message: "Please attach a document",
type: SnackbarType.error,
);
return;
}
// ✅ Validate file size
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;
}
}
// ✅ Validate file type
final allowedType = controller.selectedType?.allowedContentType;
if (allowedType != null && controller.selectedFileContentType != null) {
if (!allowedType
.toLowerCase()
.contains(controller.selectedFileContentType!.toLowerCase())) {
showAppSnackbar(
title: "Error",
message: "Only $allowedType files are allowed for this type",
type: SnackbarType.error,
);
return;
}
}
final payload = {
"documentId": _docIdController.text.trim(),
"name": _docNameController.text.trim(),
"description": _descriptionController.text.trim(),
"documentTypeId": controller.selectedType?.id,
"attachment": {
"fileName": controller.selectedFileName,
"base64Data": controller.selectedFileBase64,
"contentType": controller.selectedFileContentType,
"fileSize": controller.selectedFileSize,
"isActive": true,
},
"tags": controller.enteredTags
.map((t) => {"name": t, "isActive": true})
.toList(),
};
widget.onSubmit(payload);
showAppSnackbar(
title: "Success",
message: "Document submitted successfully",
type: SnackbarType.success,
);
}
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;
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: "Upload 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: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Document Name
LabeledInput(
label: "Document Name",
hint: "e.g., PAN Card",
controller: _docNameController,
validator: (value) =>
value == null || value.trim().isEmpty ? "Required" : null,
isRequired: true,
),
MySpacing.height(16),
/// Document Category
Obx(() {
if (controller.isLoading.value &&
controller.categories.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
return LabeledDropdown(
label: "Document Category",
hint: "Select Category",
value: controller.selectedCategory?.name,
items: controller.categories.map((c) => c.name).toList(),
onChanged: (selected) async {
final category = controller.categories
.firstWhere((c) => c.name == selected);
setState(() => controller.selectedCategory = category);
await controller.fetchDocumentTypes(category.id);
},
isRequired: true,
);
}),
MySpacing.height(16),
/// Document Type
Obx(() {
if (controller.documentTypes.isEmpty) {
return const SizedBox.shrink();
}
return LabeledDropdown(
label: "Document Type",
hint: "Select Type",
value: controller.selectedType?.name,
items: controller.documentTypes.map((t) => t.name).toList(),
onChanged: (selected) {
final type = controller.documentTypes
.firstWhere((t) => t.name == selected);
setState(() => controller.selectedType = type);
},
isRequired: true,
);
}),
MySpacing.height(24),
/// Single Attachment Section
AttachmentSectionSingle(
attachment: selectedFile,
onPick: _pickFile,
onRemove: () => setState(() {
selectedFile = null;
controller.selectedFileName = null;
controller.selectedFileBase64 = null;
controller.selectedFileContentType = null;
controller.selectedFileSize = 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
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: (value) =>
value == null || value.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 ----------------
class AttachmentSectionSingle extends StatelessWidget {
final File? attachment;
final VoidCallback onPick;
final VoidCallback? onRemove;
const AttachmentSectionSingle({
Key? key,
this.attachment,
required this.onPick,
this.onRemove,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final allowedImageExtensions = ['jpg', 'jpeg', 'png'];
Widget buildTile(File file) {
final isImage = allowedImageExtensions
.contains(file.path.split('.').last.toLowerCase());
final fileName = file.path.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: () {
if (isImage) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources: [file],
initialIndex: 0,
),
);
}
},
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade100,
),
child: isImage
? 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),
Text(
fileName.split('.').last.toUpperCase(),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: iconColor),
),
],
),
),
),
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,
children: [
Row(
children: const [
Text("Attachment", style: TextStyle(fontWeight: FontWeight.w600)),
Text(" *",
style:
TextStyle(color: Colors.red, fontWeight: FontWeight.bold))
],
),
const SizedBox(height: 8),
Row(
children: [
if (attachment != null)
buildTile(attachment!)
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;
const LabeledInput({
Key? key,
required this.label,
required this.hint,
required this.controller,
required this.validator,
this.isRequired = 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,
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"),
],
),
),
),
],
);
}