marco.pms.mobileapp/lib/model/dailyTaskPlanning/comment_task_bottom_sheet.dart

679 lines
23 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'dart:io';
import 'dart:math' as math;
// --- Assumed Imports (ensure these paths are correct in your project) ---
import 'package:marco/controller/task_planning/report_task_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_button.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/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/model/dailyTaskPlanning/create_task_botom_sheet.dart';
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
// --- Form Field Keys (Unchanged) ---
class _FormFieldKeys {
static const String assignedDate = 'assigned_date';
static const String assignedBy = 'assigned_by';
static const String workArea = 'work_area';
static const String activity = 'activity';
static const String plannedWork = 'planned_work';
static const String completedWork = 'completed_work';
static const String teamMembers = 'team_members';
static const String assigned = 'assigned';
static const String taskId = 'task_id';
static const String comment = 'comment';
}
// --- Main Widget: CommentTaskBottomSheet ---
class CommentTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
final VoidCallback? onCommentSuccess;
final String taskDataId;
final String workAreaId;
final String activityId;
const CommentTaskBottomSheet({
super.key,
required this.taskData,
this.onCommentSuccess,
required this.taskDataId,
required this.workAreaId,
required this.activityId,
});
@override
State<CommentTaskBottomSheet> createState() => _CommentTaskBottomSheetState();
}
class _Member {
final String firstName;
_Member(this.firstName);
}
class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
with UIMixin {
late final ReportTaskController controller;
List<Map<String, dynamic>> _sortedComments = [];
@override
void initState() {
super.initState();
controller = Get.put(ReportTaskController(),
tag: widget.taskData['taskId'] ?? UniqueKey().toString());
_initializeControllerData();
final comments = List<Map<String, dynamic>>.from(
widget.taskData['taskComments'] as List? ?? []);
comments.sort((a, b) {
final aDate = DateTime.tryParse(a['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
final bDate = DateTime.tryParse(b['date'] ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0);
return bDate.compareTo(aDate); // Newest first
});
_sortedComments = comments;
}
void _initializeControllerData() {
final data = widget.taskData;
final fieldMappings = {
_FormFieldKeys.assignedDate: data['assignedOn'],
_FormFieldKeys.assignedBy: data['assignedBy'],
_FormFieldKeys.workArea: data['location'],
_FormFieldKeys.activity: data['activity'],
_FormFieldKeys.plannedWork: data['plannedWork'],
_FormFieldKeys.completedWork: data['completedWork'],
_FormFieldKeys.teamMembers: (data['teamMembers'] as List?)?.join(', '),
_FormFieldKeys.assigned: data['assigned'],
_FormFieldKeys.taskId: data['taskId'],
};
for (final entry in fieldMappings.entries) {
controller.basicValidator.getController(entry.key)?.text =
entry.value ?? '';
}
controller.basicValidator.getController(_FormFieldKeys.comment)?.clear();
controller.selectedImages.clear();
}
String _timeAgo(String dateString) {
// This logic remains unchanged
try {
final date = DateTime.parse(dateString + "Z").toLocal();
final difference = DateTime.now().difference(date);
if (difference.inDays > 8) return DateFormat('dd-MM-yyyy').format(date);
if (difference.inDays >= 1)
return '${difference.inDays} day${difference.inDays > 1 ? 's' : ''} ago';
if (difference.inHours >= 1)
return '${difference.inHours} hr${difference.inHours > 1 ? 's' : ''} ago';
if (difference.inMinutes >= 1)
return '${difference.inMinutes} min${difference.inMinutes > 1 ? 's' : ''} ago';
return 'just now';
} catch (e) {
debugPrint('Error parsing date for timeAgo: $e');
return dateString;
}
}
@override
Widget build(BuildContext context) {
// --- REFACTORING POINT ---
// The entire widget now returns a BaseBottomSheet, passing the content as its child.
// The GetBuilder provides reactive state (like isLoading) to the BaseBottomSheet.
return GetBuilder<ReportTaskController>(
tag: widget.taskData['taskId'] ?? '',
builder: (controller) {
return BaseBottomSheet(
title: "Task Details & Comments",
onCancel: () => Navigator.of(context).pop(),
onSubmit: _submitComment,
isSubmitting: controller.isLoading.value,
bottomContent: _buildCommentsSection(),
child: Form(
// moved to last
key: controller.basicValidator.formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHeaderActions(),
MySpacing.height(12),
_buildTaskDetails(),
_buildReportedImages(),
_buildCommentInput(),
_buildImagePicker(),
],
),
),
);
},
);
}
// --- REFACTORING POINT ---
// The original _buildHeader is now split. The title is handled by BaseBottomSheet.
// This new widget contains the remaining actions from the header.
Widget _buildHeaderActions() {
return Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () => _showCreateTaskBottomSheet(),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.blueAccent.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
),
child: MyText.bodySmall(
"+ Create Task",
fontWeight: 600,
color: Colors.blueAccent,
),
),
),
);
}
Widget _buildTaskDetails() {
return Column(
children: [
_buildDetailRow(
"Assigned By",
controller.basicValidator
.getController(_FormFieldKeys.assignedBy)
?.text,
icon: Icons.person_outline),
_buildDetailRow(
"Work Area",
controller.basicValidator
.getController(_FormFieldKeys.workArea)
?.text,
icon: Icons.place_outlined),
_buildDetailRow(
"Activity",
controller.basicValidator
.getController(_FormFieldKeys.activity)
?.text,
icon: Icons.assignment_outlined),
_buildDetailRow(
"Planned Work",
controller.basicValidator
.getController(_FormFieldKeys.plannedWork)
?.text,
icon: Icons.schedule_outlined),
_buildDetailRow(
"Completed Work",
controller.basicValidator
.getController(_FormFieldKeys.completedWork)
?.text,
icon: Icons.done_all_outlined),
_buildTeamMembers(),
],
);
}
Widget _buildReportedImages() {
final imageUrls =
List<String>.from(widget.taskData['reportedPreSignedUrls'] ?? []);
if (imageUrls.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: _buildSectionHeader("Reported Images", Icons.image_outlined),
),
// --- Refactoring Note ---
// Using the reusable _ImageHorizontalListView widget.
_ImageHorizontalListView(
imageSources: imageUrls,
onPreview: (index) => _showImageViewer(imageUrls, index),
),
MySpacing.height(16),
],
);
}
Widget _buildCommentInput() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader("Add Note", Icons.comment_outlined),
MySpacing.height(8),
TextFormField(
validator:
controller.basicValidator.getValidation(_FormFieldKeys.comment),
controller:
controller.basicValidator.getController(_FormFieldKeys.comment),
keyboardType: TextInputType.multiline,
maxLines: null, // Allows for multiline input
decoration: InputDecoration(
hintText: "eg: Work done successfully",
hintStyle: MyTextStyle.bodySmall(xMuted: true),
border: outlineInputBorder,
enabledBorder: outlineInputBorder,
focusedBorder: focusedInputBorder,
contentPadding: MySpacing.all(16),
isCollapsed: true,
floatingLabelBehavior: FloatingLabelBehavior.never,
),
),
MySpacing.height(16),
],
);
}
Widget _buildImagePicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionHeader("Attach Photos", Icons.camera_alt_outlined),
MySpacing.height(12),
Obx(() {
final images = controller.selectedImages;
return Column(
children: [
// --- Refactoring Note ---
// Using the reusable _ImageHorizontalListView for picked images.
_ImageHorizontalListView(
imageSources: images.toList(),
onPreview: (index) => _showImageViewer(images.toList(), index),
onRemove: (index) => controller.removeImageAt(index),
emptyStatePlaceholder: Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300, width: 1.5),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_library_outlined,
size: 36, color: Colors.grey.shade400),
),
),
),
MySpacing.height(16),
Row(
children: [
_buildPickerButton(
onTap: () => controller.pickImages(fromCamera: true),
icon: Icons.camera_alt,
label: 'Capture',
),
MySpacing.width(12),
_buildPickerButton(
onTap: () => controller.pickImages(fromCamera: false),
icon: Icons.upload_file,
label: 'Upload',
),
],
),
],
);
}),
],
);
}
Widget _buildCommentsSection() {
if (_sortedComments.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MySpacing.height(24),
_buildSectionHeader("Comments", Icons.chat_bubble_outline),
MySpacing.height(12),
// --- Refactoring Note ---
// Using a ListView instead of a fixed-height SizedBox for better responsiveness.
// It's constrained by the parent SingleChildScrollView.
ListView.builder(
shrinkWrap:
true, // Important for ListView inside SingleChildScrollView
physics:
const NeverScrollableScrollPhysics(), // Parent handles scrolling
itemCount: _sortedComments.length,
itemBuilder: (context, index) {
final comment = _sortedComments[index];
// --- Refactoring Note ---
// Extracted the comment item into its own widget for clarity.
return _CommentCard(
comment: comment,
timeAgo: _timeAgo(comment['date'] ?? ''),
onPreviewImage: (imageUrls, idx) =>
_showImageViewer(imageUrls, idx),
);
},
),
],
);
}
// --- Helper and Builder methods ---
Widget _buildDetailRow(String label, String? value,
{required IconData icon}) {
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8.0, top: 2),
child: Icon(icon, size: 18, color: Colors.grey[700]),
),
MyText.titleSmall("$label:", fontWeight: 600),
MySpacing.width(12),
Expanded(
child: MyText.bodyMedium(
value != null && value.isNotEmpty ? value : "-",
color: Colors.black87,
),
),
],
),
);
}
Widget _buildSectionHeader(String title, IconData icon) {
return Row(
children: [
Icon(icon, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall(title, fontWeight: 600),
],
);
}
Widget _buildTeamMembers() {
final teamMembersText = controller.basicValidator
.getController(_FormFieldKeys.teamMembers)
?.text ??
'';
final members = teamMembersText
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
if (members.isEmpty) return const SizedBox.shrink();
const double avatarSize = 32.0;
const double avatarOverlap = 22.0;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Row(
children: [
Icon(Icons.group_outlined, size: 18, color: Colors.grey[700]),
MySpacing.width(8),
MyText.titleSmall("Team:", fontWeight: 600),
MySpacing.width(12),
GestureDetector(
onTap: () => TeamBottomSheet.show(
context: context,
teamMembers: members.map((name) => _Member(name)).toList()),
child: SizedBox(
height: avatarSize,
// Calculate width based on number of avatars shown
width: (math.min(members.length, 3) * avatarOverlap) +
(avatarSize - avatarOverlap),
child: Stack(
children: [
...List.generate(math.min(members.length, 3), (i) {
return Positioned(
left: i * avatarOverlap,
child: Tooltip(
message: members[i],
child: Avatar(
firstName: members[i],
lastName: '',
size: avatarSize),
),
);
}),
if (members.length > 3)
Positioned(
left: 3 * avatarOverlap,
child: CircleAvatar(
radius: avatarSize / 2,
backgroundColor: Colors.grey.shade300,
child: MyText.bodySmall('+${members.length - 3}',
fontWeight: 600),
),
),
],
),
),
),
],
),
);
}
Widget _buildPickerButton(
{required VoidCallback onTap,
required IconData icon,
required String label}) {
return Expanded(
child: MyButton.outlined(
onPressed: onTap,
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: Colors.blueAccent),
MySpacing.width(8),
MyText.bodySmall(label, color: Colors.blueAccent, fontWeight: 600),
],
),
),
);
}
// --- Action Handlers ---
void _showCreateTaskBottomSheet() {
showCreateTaskBottomSheet(
workArea: widget.taskData['location'] ?? '',
activity: widget.taskData['activity'] ?? '',
completedWork: widget.taskData['completedWork'] ?? '',
unit: widget.taskData['unit'] ?? '',
onCategoryChanged: (category) =>
debugPrint("Category changed to: $category"),
parentTaskId: widget.taskDataId,
plannedTask: int.tryParse(widget.taskData['plannedWork'] ?? '0') ?? 0,
activityId: widget.activityId,
workAreaId: widget.workAreaId,
onSubmit: () => Navigator.of(context).pop(),
);
}
void _showImageViewer(List<dynamic> sources, int initialIndex) {
showDialog(
context: context,
barrierColor: Colors.black87,
builder: (_) => ImageViewerDialog(
imageSources: sources,
initialIndex: initialIndex,
),
);
}
Future<void> _submitComment() async {
if (controller.basicValidator.validateForm()) {
await controller.commentTask(
projectId: controller.basicValidator
.getController(_FormFieldKeys.taskId)
?.text ??
'',
comment: controller.basicValidator
.getController(_FormFieldKeys.comment)
?.text ??
'',
images: controller.selectedImages,
);
// Callback to the parent widget to refresh data if needed
widget.onCommentSuccess?.call();
}
}
}
// --- Refactoring Note ---
// A reusable widget for displaying a horizontal list of images.
// It can handle both network URLs (String) and local files (File).
class _ImageHorizontalListView extends StatelessWidget {
final List<dynamic> imageSources; // Can be List<String> or List<File>
final Function(int) onPreview;
final Function(int)? onRemove;
final Widget? emptyStatePlaceholder;
const _ImageHorizontalListView({
required this.imageSources,
required this.onPreview,
this.onRemove,
this.emptyStatePlaceholder,
});
@override
Widget build(BuildContext context) {
if (imageSources.isEmpty) {
return emptyStatePlaceholder ?? const SizedBox.shrink();
}
return SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: imageSources.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (context, index) {
final source = imageSources[index];
return GestureDetector(
onTap: () => onPreview(index),
child: Stack(
clipBehavior: Clip.none,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: source is File
? Image.file(source,
width: 70, height: 70, fit: BoxFit.cover)
: Image.network(
source as String,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
Container(
width: 70,
height: 70,
color: Colors.grey.shade200,
child: Icon(Icons.broken_image,
color: Colors.grey[600]),
),
),
),
if (onRemove != null)
Positioned(
top: -6,
right: -6,
child: GestureDetector(
onTap: () => onRemove!(index),
child: Container(
padding: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.red, shape: BoxShape.circle),
child: const Icon(Icons.close,
size: 16, color: Colors.white),
),
),
),
],
),
);
},
),
);
}
}
// --- Refactoring Note ---
// A dedicated widget for a single comment card. This cleans up the main
// widget's build method and makes the comment layout easier to manage.
class _CommentCard extends StatelessWidget {
final Map<String, dynamic> comment;
final String timeAgo;
final Function(List<String> imageUrls, int index) onPreviewImage;
const _CommentCard({
required this.comment,
required this.timeAgo,
required this.onPreviewImage,
});
@override
Widget build(BuildContext context) {
final commentedBy = comment['commentedBy'] ?? 'Unknown';
final commentText = comment['text'] ?? '-';
final imageUrls = List<String>.from(comment['preSignedUrls'] ?? []);
return Container(
margin: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Avatar(
firstName: commentedBy.split(' ').first,
lastName: commentedBy.split(' ').length > 1
? commentedBy.split(' ').last
: '',
size: 32,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(commentedBy,
fontWeight: 700, color: Colors.black87),
MyText.bodySmall(timeAgo,
color: Colors.black54, fontSize: 12),
],
),
),
],
),
MySpacing.height(12),
MyText.bodyMedium(commentText, color: Colors.black87),
if (imageUrls.isNotEmpty) ...[
MySpacing.height(12),
_ImageHorizontalListView(
imageSources: imageUrls,
onPreview: (index) => onPreviewImage(imageUrls, index),
),
],
],
),
);
}
}