feat: Enhance project and task management features

- Added clearProjects method in ProjectController to reset project states.
- Updated fetchProjects and updateSelectedProject methods for better state management.
- Enhanced ReportTaskController to support image uploads with base64 encoding.
- Modified ApiService to handle image data in report and comment tasks.
- Integrated ProjectController in AuthService to fetch projects upon login.
- Updated LocalStorage to clear selectedProjectId on logout.
- Introduced ImageViewerDialog for displaying images in a dialog.
- Enhanced CommentTaskBottomSheet and ReportTaskBottomSheet to support image attachments.
- Improved AttendanceScreen to handle project selection and data fetching more robustly.
- Refactored EmployeesScreen to manage employee data based on project selection.
- Updated Layout to handle project selection and display appropriate messages.
- Enhanced DailyProgressReportScreen and DailyTaskPlaningScreen to reactively fetch task data based on project changes.
- Added photo_view dependency for improved image handling.
This commit is contained in:
Vaibhav Surve 2025-06-12 17:28:06 +05:30
parent b38d987eac
commit 3a449441fa
17 changed files with 982 additions and 238 deletions

View File

@ -5,6 +5,7 @@ import 'package:marco/model/attendance_model.dart';
import 'package:marco/model/project_model.dart'; import 'package:marco/model/project_model.dart';
import 'package:marco/model/employee_model.dart'; import 'package:marco/model/employee_model.dart';
import 'package:marco/model/employees/employee_details_model.dart'; import 'package:marco/model/employees/employee_details_model.dart';
import 'package:marco/controller/project_controller.dart';
final Logger log = Logger(); final Logger log = Logger();
@ -12,8 +13,9 @@ class EmployeesScreenController extends GetxController {
List<AttendanceModel> attendances = []; List<AttendanceModel> attendances = [];
List<ProjectModel> projects = []; List<ProjectModel> projects = [];
String? selectedProjectId; String? selectedProjectId;
List<EmployeeModel> employees = [];
List<EmployeeDetailsModel> employeeDetails = []; List<EmployeeDetailsModel> employeeDetails = [];
RxBool isAllEmployeeSelected = false.obs;
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs; RxBool isLoading = false.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
@ -24,7 +26,17 @@ class EmployeesScreenController extends GetxController {
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchAllProjects(); fetchAllProjects();
final projectId = Get.find<ProjectController>().selectedProject?.id;
if (projectId != null) {
selectedProjectId = projectId;
fetchEmployeesByProject(projectId);
} else if (isAllEmployeeSelected.value) {
fetchAllEmployees(); fetchAllEmployees();
} else {
clearEmployees();
}
} }
Future<void> fetchAllProjects() async { Future<void> fetchAllProjects() async {
@ -41,18 +53,27 @@ class EmployeesScreenController extends GetxController {
update(); update();
} }
void clearEmployees() {
employees.clear(); // Correct way to clear RxList
log.i("Employees cleared");
update(['employee_screen_controller']);
}
Future<void> fetchAllEmployees() async { Future<void> fetchAllEmployees() async {
isLoading.value = true; isLoading.value = true;
await _handleApiCall( await _handleApiCall(
ApiService.getAllEmployees, ApiService.getAllEmployees,
onSuccess: (data) { onSuccess: (data) {
employees = data.map((json) => EmployeeModel.fromJson(json)).toList(); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
log.i("All Employees fetched: ${employees.length} employees loaded."); log.i("All Employees fetched: ${employees.length} employees loaded.");
}, },
onEmpty: () => log.w("No Employee data found or API call failed."), onEmpty: () {
employees.clear(); // Always clear on empty
log.w("No Employee data found or API call failed.");
},
); );
isLoading.value = false; isLoading.value = false;
update(); update(['employee_screen_controller']);
} }
Future<void> fetchEmployeesByProject(String? projectId) async { Future<void> fetchEmployeesByProject(String? projectId) async {
@ -65,22 +86,21 @@ class EmployeesScreenController extends GetxController {
await _handleApiCall( await _handleApiCall(
() => ApiService.getAllEmployeesByProject(projectId), () => ApiService.getAllEmployeesByProject(projectId),
onSuccess: (data) { onSuccess: (data) {
employees = data.map((json) => EmployeeModel.fromJson(json)).toList(); employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
for (var emp in employees) { for (var emp in employees) {
uploadingStates[emp.id] = false.obs; uploadingStates[emp.id] = false.obs;
} }
log.i("Employees fetched: ${employees.length} for project $projectId"); log.i("Employees fetched: ${employees.length} for project $projectId");
update();
}, },
onEmpty: () { onEmpty: () {
employees.clear();
log.w("No employees found for project $projectId."); log.w("No employees found for project $projectId.");
employees = [];
update();
}, },
onError: (e) => onError: (e) =>
log.e("Error fetching employees for project $projectId: $e"), log.e("Error fetching employees for project $projectId: $e"),
); );
isLoading.value = false; isLoading.value = false;
update(['employee_screen_controller']);
} }
Future<void> _handleApiCall( Future<void> _handleApiCall(

View File

@ -28,6 +28,19 @@ class ProjectController extends GetxController {
fetchProjects(); fetchProjects();
} }
void clearProjects() {
projects.clear();
selectedProjectId = null;
isProjectSelectionExpanded.value = false;
isProjectListExpanded.value = false;
isProjectDropdownExpanded.value = false;
isLoadingProjects.value = false;
isLoading.value = false;
uploadingStates.clear();
LocalStorage.saveString('selectedProjectId', '');
update();
}
/// Fetches projects and initializes selected project. /// Fetches projects and initializes selected project.
Future<void> fetchProjects() async { Future<void> fetchProjects() async {
isLoadingProjects.value = true; isLoadingProjects.value = true;
@ -62,6 +75,8 @@ class ProjectController extends GetxController {
Future<void> updateSelectedProject(String projectId) async { Future<void> updateSelectedProject(String projectId) async {
selectedProjectId?.value = projectId; selectedProjectId?.value = projectId;
await LocalStorage.saveString('selectedProjectId', projectId); await LocalStorage.saveString('selectedProjectId', projectId);
update(); update([
'selected_project'
]);
} }
} }

View File

@ -9,6 +9,7 @@ import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/task_planing/daily_task_planing_controller.dart'; import 'package:marco/controller/task_planing/daily_task_planing_controller.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'dart:io'; import 'dart:io';
import 'dart:convert';
final Logger logger = Logger(); final Logger logger = Logger();
@ -96,6 +97,7 @@ class ReportTaskController extends MyController {
required int completedTask, required int completedTask,
required List<Map<String, dynamic>> checklist, required List<Map<String, dynamic>> checklist,
required DateTime reportedDate, required DateTime reportedDate,
List<File>? images,
}) async { }) async {
logger.i("Starting task report..."); logger.i("Starting task report...");
@ -132,12 +134,24 @@ class ReportTaskController extends MyController {
try { try {
isLoading.value = true; isLoading.value = true;
List<Map<String, dynamic>>? imageData;
if (images != null && images.isNotEmpty) {
imageData = await Future.wait(images.map((file) async {
final bytes = await file.readAsBytes();
final base64Image = base64Encode(bytes);
return {
"fileName": file.path.split('/').last,
"fileData": base64Image,
};
}));
}
final success = await ApiService.reportTask( final success = await ApiService.reportTask(
id: projectId, id: projectId,
comment: commentField, comment: commentField,
completedTask: completedTask, completedTask: completedWorkInt,
checkList: checklist, checkList: checklist,
images: imageData,
); );
if (success) { if (success) {
@ -169,6 +183,7 @@ class ReportTaskController extends MyController {
Future<void> commentTask({ Future<void> commentTask({
required String projectId, required String projectId,
required String comment, required String comment,
List<File>? images,
}) async { }) async {
logger.i("Starting task comment..."); logger.i("Starting task comment...");
@ -184,10 +199,22 @@ class ReportTaskController extends MyController {
try { try {
isLoading.value = true; isLoading.value = true;
List<Map<String, dynamic>>? imageData;
if (images != null && images.isNotEmpty) {
imageData = await Future.wait(images.map((file) async {
final bytes = await file.readAsBytes();
final base64Image = base64Encode(bytes);
return {
"fileName": file.path.split('/').last,
"fileData": base64Image,
};
}));
}
final success = await ApiService.commentTask( final success = await ApiService.commentTask(
id: projectId, id: projectId,
comment: commentField, comment: commentField,
images: imageData,
); );
if (success) { if (success) {

View File

@ -343,6 +343,7 @@ class ApiService {
required int completedTask, required int completedTask,
required String comment, required String comment,
required List<Map<String, dynamic>> checkList, required List<Map<String, dynamic>> checkList,
List<Map<String, dynamic>>? images,
}) async { }) async {
final body = { final body = {
"id": id, "id": id,
@ -350,6 +351,7 @@ class ApiService {
"comment": comment, "comment": comment,
"reportedDate": DateTime.now().toUtc().toIso8601String(), "reportedDate": DateTime.now().toUtc().toIso8601String(),
"checkList": checkList, "checkList": checkList,
if (images != null && images.isNotEmpty) "images": images,
}; };
final response = await _postRequest(ApiEndpoints.reportTask, body); final response = await _postRequest(ApiEndpoints.reportTask, body);
@ -373,11 +375,13 @@ class ApiService {
static Future<bool> commentTask({ static Future<bool> commentTask({
required String id, required String id,
required String comment, required String comment,
List<Map<String, dynamic>>? images,
}) async { }) async {
final body = { final body = {
"taskAllocationId": id, "taskAllocationId": id,
"comment": comment, "comment": comment,
"commentDate": DateTime.now().toUtc().toIso8601String(), "commentDate": DateTime.now().toUtc().toIso8601String(),
if (images != null && images.isNotEmpty) "images": images,
}; };
final response = await _postRequest(ApiEndpoints.commentTask, body); final response = await _postRequest(ApiEndpoints.commentTask, body);

View File

@ -5,6 +5,7 @@ import 'package:marco/helpers/services/storage/local_storage.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:marco/helpers/services/api_endpoints.dart'; import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/controller/project_controller.dart';
final Logger logger = Logger(); final Logger logger = Logger();
@ -370,6 +371,7 @@ class AuthService {
// Put and load PermissionController // Put and load PermissionController
final permissionController = Get.put(PermissionController()); final permissionController = Get.put(PermissionController());
await permissionController.loadData(jwtToken); await permissionController.loadData(jwtToken);
await Get.find<ProjectController>().fetchProjects();
isLoggedIn = true; isLoggedIn = true;
} }

View File

@ -5,7 +5,9 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:marco/model/user_permission.dart'; import 'package:marco/model/user_permission.dart';
import 'package:marco/model/employee_info.dart'; import 'package:marco/model/employee_info.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:get/route_manager.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart';
class LocalStorage { class LocalStorage {
static const String _loggedInUserKey = "user"; static const String _loggedInUserKey = "user";
@ -145,6 +147,11 @@ class LocalStorage {
await preferences.remove("mpin_verified"); await preferences.remove("mpin_verified");
await preferences.remove(_languageKey); await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey); await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId');
if (Get.isRegistered<ProjectController>()) {
Get.find<ProjectController>().clearProjects();
}
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }

View File

@ -0,0 +1,122 @@
import 'dart:io';
import 'package:flutter/material.dart';
class ImageViewerDialog extends StatefulWidget {
final List<dynamic> imageSources;
final int initialIndex;
const ImageViewerDialog({
Key? key,
required this.imageSources,
required this.initialIndex,
}) : super(key: key);
@override
State<ImageViewerDialog> createState() => _ImageViewerDialogState();
}
class _ImageViewerDialogState extends State<ImageViewerDialog> {
late final PageController _controller;
late int currentIndex;
bool isFile(dynamic item) => item is File;
@override
void initState() {
super.initState();
currentIndex = widget.initialIndex;
_controller = PageController(initialPage: widget.initialIndex);
}
@override
Widget build(BuildContext context) {
final double dialogHeight = MediaQuery.of(context).size.height * 0.55;
return Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 100),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Container(
height: dialogHeight,
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
// Top Close Button
Align(
alignment: Alignment.topRight,
child: IconButton(
icon: const Icon(Icons.close, size: 26),
onPressed: () => Navigator.of(context).pop(),
splashRadius: 22,
tooltip: 'Close',
),
),
// Image Viewer
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: widget.imageSources.length,
onPageChanged: (index) {
setState(() => currentIndex = index);
},
itemBuilder: (context, index) {
final item = widget.imageSources[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: isFile(item)
? Image.file(item, fit: BoxFit.contain)
: Image.network(
item,
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
return Center(
child: CircularProgressIndicator(
value: loadingProgress.expectedTotalBytes != null
? loadingProgress.cumulativeBytesLoaded /
(loadingProgress.expectedTotalBytes ?? 1)
: null,
),
);
},
errorBuilder: (context, error, stackTrace) =>
const Center(
child: Icon(Icons.broken_image,
size: 48, color: Colors.grey),
),
),
);
},
),
),
// Index Indicator
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 12),
child: Text(
'${currentIndex + 1} / ${widget.imageSources.length}',
style: const TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
}
}

View File

@ -10,6 +10,7 @@ import 'package:marco/helpers/widgets/my_text_style.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/helpers/widgets/my_team_model_sheet.dart'; import 'package:marco/helpers/widgets/my_team_model_sheet.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
class CommentTaskBottomSheet extends StatefulWidget { class CommentTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData; final Map<String, dynamic> taskData;
@ -206,6 +207,148 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
floatingLabelBehavior: FloatingLabelBehavior.never, floatingLabelBehavior: FloatingLabelBehavior.never,
), ),
), ),
MySpacing.height(16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.camera_alt_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Attach Photos:",
fontWeight: 600),
MySpacing.height(12),
],
),
),
],
),
Obx(() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) =>
MySpacing.width(12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) =>
ImageViewerDialog(
imageSources: images,
initialIndex: index,
),
);
},
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller
.removeImageAt(index),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(Icons.close,
size: 20,
color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture',
color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.upload_file,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload',
color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}),
MySpacing.height(24), MySpacing.height(24),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
@ -233,7 +376,9 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
.getController('comment') .getController('comment')
?.text ?? ?.text ??
'', '',
images: controller.selectedImages,
); );
if (widget.onCommentSuccess != null) { if (widget.onCommentSuccess != null) {
widget.onCommentSuccess!(); widget.onCommentSuccess!();
} }
@ -262,12 +407,13 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
}), }),
], ],
), ),
MySpacing.height(24), MySpacing.height(10),
if ((widget.taskData['taskComments'] as List<dynamic>?) if ((widget.taskData['taskComments'] as List<dynamic>?)
?.isNotEmpty == ?.isNotEmpty ==
true) ...[ true) ...[
Row( Row(
children: [ children: [
MySpacing.width(10),
Icon(Icons.chat_bubble_outline, Icon(Icons.chat_bubble_outline,
size: 18, color: Colors.grey[700]), size: 18, color: Colors.grey[700]),
MySpacing.width(8), MySpacing.width(8),
@ -277,6 +423,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
), ),
], ],
), ),
Divider(),
MySpacing.height(12), MySpacing.height(12),
Builder( Builder(
builder: (context) { builder: (context) {
@ -301,81 +448,233 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
itemCount: comments.length, itemCount: comments.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final comment = comments[index]; final comment = comments[index];
final commentText = comment['text'] ?? '-'; final commentText =
comment['text'] ?? '-';
final commentedBy = final commentedBy =
comment['commentedBy'] ?? 'Unknown'; comment['commentedBy'] ?? 'Unknown';
final relativeTime = final relativeTime =
timeAgo(comment['date'] ?? ''); timeAgo(comment['date'] ?? '');
// Dummy image URLs (simulate as if coming from backend)
final imageUrls = [
'https://picsum.photos/seed/${index}a/100',
'https://picsum.photos/seed/${index}b/100',
'https://picsum.photos/seed/${index}a/100',
'https://picsum.photos/seed/${index}b/100',
'https://picsum.photos/seed/${index}a/100',
'https://picsum.photos/seed/${index}b/100',
];
return Container( return Container(
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
vertical: 6, horizontal: 8), vertical: 0, horizontal: 0),
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade200, color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12), borderRadius:
BorderRadius.circular(12),
), ),
child: Row( child: Row(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
// Avatar for commenter
Avatar(
firstName:
commentedBy.split(' ').first,
lastName: commentedBy
.split(' ')
.length >
1
? commentedBy.split(' ').last
: '',
size: 32,
),
SizedBox(width: 12), SizedBox(width: 12),
// Comment text and meta
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
CrossAxisAlignment.start, CrossAxisAlignment.start,
children: [ children: [
// 🔹 Top Row: Avatar + Name + Time
Row(
crossAxisAlignment:
CrossAxisAlignment
.center,
children: [
Avatar(
firstName: commentedBy
.split(' ')
.first,
lastName: commentedBy
.split(' ')
.length >
1
? commentedBy
.split(' ')
.last
: '',
size: 32,
),
SizedBox(width: 12),
Expanded(
child: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
MyText.bodyMedium(
commentedBy,
fontWeight: 700,
color: Colors
.black87,
),
MyText.bodySmall(
relativeTime,
fontSize: 12,
color: Colors
.black54,
),
],
),
),
],
),
SizedBox(height: 12),
// 🔹 Comment text below attachments
Row( Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment MainAxisAlignment
.spaceBetween, .spaceBetween,
children: [ children: [
Text( MyText.bodyMedium(
commentedBy, commentText,
style: TextStyle( fontWeight: 500,
fontWeight:
FontWeight.bold,
color: Colors.black87, color: Colors.black87,
), ),
]),
SizedBox(height: 12),
// 🔹 Attachments row: full width below top row
if (imageUrls.isNotEmpty) ...[
Row(
crossAxisAlignment:
CrossAxisAlignment
.start,
children: [
Icon(
Icons
.attach_file_outlined,
size: 18,
color: Colors
.grey[700]),
MyText.bodyMedium(
'Attachments',
fontWeight: 600,
color:
Colors.black87,
), ),
Text( ]),
relativeTime, SizedBox(height: 8),
style: TextStyle( SizedBox(
fontSize: 12, height: 60,
color: Colors.black54, child: ListView.separated(
padding: EdgeInsets
.symmetric(
horizontal: 0),
scrollDirection:
Axis.horizontal,
itemCount:
imageUrls.length,
itemBuilder: (context,
imageIndex) {
final imageUrl =
imageUrls[
imageIndex];
return GestureDetector(
onTap: () {
showDialog(
context:
context,
barrierColor:
Colors
.black54,
builder: (_) =>
ImageViewerDialog(
imageSources:
imageUrls,
initialIndex:
imageIndex,
),
);
},
child: Stack(
children: [
Container(
width: 60,
height: 60,
decoration:
BoxDecoration(
borderRadius:
BorderRadius.circular(
12),
color: Colors
.grey[
100],
boxShadow: [
BoxShadow(
color: Colors
.black26,
blurRadius:
6,
offset: Offset(
2,
2),
), ),
)
], ],
), ),
SizedBox(height: 6), child:
Text( ClipRRect(
commentText, borderRadius:
style: TextStyle( BorderRadius.circular(
fontWeight: FontWeight.w500, 12),
color: Colors.black87, child: Image
.network(
imageUrl,
fit: BoxFit
.cover,
errorBuilder: (context,
error,
stackTrace) =>
Container(
color: Colors
.grey[300],
child: Icon(
Icons
.broken_image,
color:
Colors.grey[700]),
), ),
), ),
),
),
Positioned(
right: 4,
bottom: 4,
child: Icon(
Icons
.zoom_in,
color: Colors
.white70,
size: 16),
),
],
),
);
},
separatorBuilder: (_,
__) =>
SizedBox(width: 12),
),
),
SizedBox(height: 12),
],
], ],
), ),
), ),
], ],
), ),
); );
}, }),
),
); );
}, },
), ),

View File

@ -11,7 +11,11 @@ import 'package:marco/helpers/widgets/my_text_style.dart';
class ReportTaskBottomSheet extends StatefulWidget { class ReportTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData; final Map<String, dynamic> taskData;
final VoidCallback? onReportSuccess; final VoidCallback? onReportSuccess;
const ReportTaskBottomSheet({super.key, required this.taskData,this.onReportSuccess,}); const ReportTaskBottomSheet({
super.key,
required this.taskData,
this.onReportSuccess,
});
@override @override
State<ReportTaskBottomSheet> createState() => _ReportTaskBottomSheetState(); State<ReportTaskBottomSheet> createState() => _ReportTaskBottomSheetState();
@ -201,6 +205,147 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
), ),
), ),
MySpacing.height(24), MySpacing.height(24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.camera_alt_outlined,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall("Attach Photos:",
fontWeight: 600),
MySpacing.height(12),
],
),
),
],
),
Obx(() {
final images = controller.selectedImages;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (images.isEmpty)
Container(
height: 70,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.grey.shade300, width: 2),
color: Colors.grey.shade100,
),
child: Center(
child: Icon(Icons.photo_camera_outlined,
size: 48, color: Colors.grey.shade400),
),
)
else
SizedBox(
height: 70,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: images.length,
separatorBuilder: (_, __) =>
MySpacing.width(12),
itemBuilder: (context, index) {
final file = images[index];
return Stack(
children: [
GestureDetector(
onTap: () {
showDialog(
context: context,
builder: (_) => Dialog(
child: InteractiveViewer(
child: Image.file(file),
),
),
);
},
child: ClipRRect(
borderRadius:
BorderRadius.circular(12),
child: Image.file(
file,
height: 70,
width: 70,
fit: BoxFit.cover,
),
),
),
Positioned(
top: 4,
right: 4,
child: GestureDetector(
onTap: () => controller
.removeImageAt(index),
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: Icon(Icons.close,
size: 20,
color: Colors.white),
),
),
),
],
);
},
),
),
MySpacing.height(16),
Row(
children: [
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: true),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Capture',
color: Colors.blueAccent),
],
),
),
),
MySpacing.width(12),
Expanded(
child: MyButton.outlined(
onPressed: () => controller.pickImages(
fromCamera: false),
padding: MySpacing.xy(12, 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.upload_file,
size: 16,
color: Colors.blueAccent),
MySpacing.width(6),
MyText.bodySmall('Upload',
color: Colors.blueAccent),
],
),
),
),
],
),
],
);
}),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -237,6 +382,7 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
0, 0,
checklist: [], checklist: [],
reportedDate: DateTime.now(), reportedDate: DateTime.now(),
images: controller.selectedImages,
); );
if (widget.onReportSuccess != null) { if (widget.onReportSuccess != null) {
widget.onReportSuccess!(); widget.onReportSuccess!();

View File

@ -236,8 +236,17 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
), ),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly], inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => onChanged: (value) {
controller.onDigitChanged(value, index, isRetype: isRetype), controller.onDigitChanged(value, index, isRetype: isRetype);
if (!isRetype) {
final isComplete =
controller.digitControllers.every((c) => c.text.isNotEmpty);
if (isComplete && !controller.isLoading.value) {
controller.onSubmitMPIN();
}
}
},
decoration: InputDecoration( decoration: InputDecoration(
counterText: '', counterText: '',
filled: true, filled: true,

View File

@ -36,8 +36,14 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
ever<String?>(projectController.selectedProjectId!, (projectId) async { final attendanceController = Get.find<AttendanceController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
ever<String?>(
projectController.selectedProjectId!,
(projectId) async {
if (projectId != null && projectId.isNotEmpty) { if (projectId != null && projectId.isNotEmpty) {
try { try {
await attendanceController.fetchEmployeesByProject(projectId); await attendanceController.fetchEmployeesByProject(projectId);
@ -49,6 +55,8 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
debugPrint("Error updating data on project change: $e"); debugPrint("Error updating data on project change: $e");
} }
} }
},
);
}); });
} }
@ -107,6 +115,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
init: attendanceController, init: attendanceController,
tag: 'attendance_dashboard_controller', tag: 'attendance_dashboard_controller',
builder: (controller) { builder: (controller) {
final selectedProjectId =
Get.find<ProjectController>().selectedProjectId?.value;
final bool noProjectSelected =
selectedProjectId == null || selectedProjectId.isEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -234,7 +248,18 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
MyFlex(children: [ MyFlex(children: [
MyFlexItem( MyFlexItem(
sizes: 'lg-12 md-12 sm-12', sizes: 'lg-12 md-12 sm-12',
child: selectedTab == 'todaysAttendance' child: noProjectSelected
? Center(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: MyText.titleMedium(
'No Records Found',
fontWeight: 600,
color: Colors.grey[600],
),
),
)
: selectedTab == 'todaysAttendance'
? employeeListTab() ? employeeListTab()
: selectedTab == 'attendanceLogs' : selectedTab == 'attendanceLogs'
? employeeLog() ? employeeLog()

View File

@ -9,12 +9,12 @@ import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/controller/permission_controller.dart'; import 'package:marco/controller/permission_controller.dart';
import 'package:marco/model/employees/employees_screen_filter_sheet.dart';
import 'package:marco/model/employees/add_employee_bottom_sheet.dart'; import 'package:marco/model/employees/add_employee_bottom_sheet.dart';
import 'package:marco/controller/dashboard/employees_screen_controller.dart'; import 'package:marco/controller/dashboard/employees_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart'; import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_detail_bottom_sheet.dart'; import 'package:marco/model/employees/employee_detail_bottom_sheet.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
class EmployeesScreen extends StatefulWidget { class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key}); const EmployeesScreen({super.key});
@ -27,52 +27,45 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Get.put(EmployeesScreenController()); Get.put(EmployeesScreenController());
final PermissionController permissionController = final PermissionController permissionController =
Get.put(PermissionController()); Get.put(PermissionController());
Future<void> _refreshEmployees() async {
Future<void> _openFilterSheet() async {
final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (context) => EmployeesScreenFilterSheet(
controller: employeeScreenController,
permissionController: permissionController,
),
);
if (result != null) {
final selectedProjectId = result['projectId'] as String?;
if (selectedProjectId != employeeScreenController.selectedProjectId) {
employeeScreenController.selectedProjectId = selectedProjectId;
try { try {
if (selectedProjectId == null) { final selectedProjectId =
Get.find<ProjectController>().selectedProject?.id;
final isAllSelected =
employeeScreenController.isAllEmployeeSelected.value;
if (isAllSelected) {
employeeScreenController.selectedProjectId = null;
await employeeScreenController.fetchAllEmployees(); await employeeScreenController.fetchAllEmployees();
} else { } else if (selectedProjectId != null) {
employeeScreenController.selectedProjectId = selectedProjectId;
await employeeScreenController await employeeScreenController
.fetchEmployeesByProject(selectedProjectId); .fetchEmployeesByProject(selectedProjectId);
} } else {
} catch (e) { // Clear employees if neither selected
debugPrint('Error fetching employees: ${e.toString()}'); employeeScreenController.clearEmployees();
} }
employeeScreenController.update(['employee_screen_controller']); employeeScreenController.update(['employee_screen_controller']);
} } catch (e, stackTrace) {
debugPrint('Error refreshing employee data: ${e.toString()}');
debugPrintStack(stackTrace: stackTrace);
} }
} }
Future<void> _refreshEmployees() async { @override
try { void initState() {
final projectId = employeeScreenController.selectedProjectId; super.initState();
if (projectId == null) { final selectedProjectId = Get.find<ProjectController>().selectedProject?.id;
await employeeScreenController.fetchAllEmployees();
if (selectedProjectId != null) {
employeeScreenController.selectedProjectId = selectedProjectId;
employeeScreenController.fetchEmployeesByProject(selectedProjectId);
} else if (employeeScreenController.isAllEmployeeSelected.value) {
employeeScreenController.selectedProjectId = null;
employeeScreenController.fetchAllEmployees();
} else { } else {
await employeeScreenController.fetchEmployeesByProject(projectId); employeeScreenController.clearEmployees();
}
} catch (e) {
debugPrint('Error refreshing employee data: ${e.toString()}');
} }
} }
@ -194,26 +187,66 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
MyText.bodyMedium("Filter", fontWeight: 600), Obx(() {
Tooltip( return Row(
message: 'Project', children: [
child: InkWell( Checkbox(
borderRadius: BorderRadius.circular(24), value: employeeScreenController
onTap: _openFilterSheet, .isAllEmployeeSelected.value,
child: MouseRegion( activeColor: Colors.blueAccent,
cursor: SystemMouseCursors.click, fillColor:
child: const Padding( MaterialStateProperty.resolveWith<Color?>(
padding: EdgeInsets.all(8), (states) {
child: Icon( if (states.contains(MaterialState.selected)) {
Icons.filter_list_alt, return Colors.blueAccent;
color: Colors.blueAccent, }
size: 28, return Colors.transparent;
}),
checkColor: Colors.white,
side: BorderSide(
color: Colors.black,
width: 2,
), ),
onChanged: (value) async {
employeeScreenController
.isAllEmployeeSelected.value = value!;
if (value) {
employeeScreenController.selectedProjectId =
null;
await employeeScreenController
.fetchAllEmployees();
} else {
final selectedProjectId =
Get.find<ProjectController>()
.selectedProject
?.id;
if (selectedProjectId != null) {
employeeScreenController
.selectedProjectId =
selectedProjectId;
await employeeScreenController
.fetchEmployeesByProject(
selectedProjectId);
} else {
// THIS is your critical path
employeeScreenController.clearEmployees();
}
}
employeeScreenController
.update(['employee_screen_controller']);
},
), ),
MyText.bodyMedium(
"All Employees",
fontWeight: 600,
), ),
), ],
), );
const SizedBox(width: 8), }),
const SizedBox(width: 16),
MyText.bodyMedium("Refresh", fontWeight: 600), MyText.bodyMedium("Refresh", fontWeight: 600),
Tooltip( Tooltip(
message: 'Refresh Data', message: 'Refresh Data',
@ -253,17 +286,18 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
return Obx(() { return Obx(() {
final isLoading = employeeScreenController.isLoading.value; final isLoading = employeeScreenController.isLoading.value;
final employees = employeeScreenController.employees; final employees = employeeScreenController.employees;
if (isLoading) { if (isLoading) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (employees.isEmpty) { if (employees.isEmpty) {
return Center( return Padding(
padding: const EdgeInsets.only(top: 50),
child: Center(
child: MyText.bodySmall( child: MyText.bodySmall(
"No Assigned Employees Found", "No Assigned Employees Found",
fontWeight: 600, fontWeight: 600,
), ),
),
); );
} }
return SingleChildScrollView( return SingleChildScrollView(

View File

@ -100,7 +100,11 @@ class _LayoutState extends State<Layout> {
(p) => p.id == selectedProjectId, (p) => p.id == selectedProjectId,
); );
if (selectedProject == null && projectController.projects.isNotEmpty) { final hasProjects = projectController.projects.isNotEmpty;
if (!hasProjects) {
projectController.selectedProjectId?.value = '';
} else if (selectedProject == null) {
projectController projectController
.updateSelectedProject(projectController.projects.first.id); .updateSelectedProject(projectController.projects.first.id);
} }
@ -111,7 +115,7 @@ class _LayoutState extends State<Layout> {
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias, // important for overlap inside card clipBehavior: Clip.antiAlias,
child: Stack( child: Stack(
children: [ children: [
Padding( Padding(
@ -129,7 +133,8 @@ class _LayoutState extends State<Layout> {
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: GestureDetector( child: hasProjects
? GestureDetector(
onTap: () => projectController onTap: () => projectController
.isProjectSelectionExpanded .isProjectSelectionExpanded
.toggle(), .toggle(),
@ -153,7 +158,8 @@ class _LayoutState extends State<Layout> {
Icon( Icon(
isExpanded isExpanded
? Icons.arrow_drop_up_outlined ? Icons.arrow_drop_up_outlined
: Icons.arrow_drop_down_outlined, : Icons
.arrow_drop_down_outlined,
color: Colors.black, color: Colors.black,
), ),
], ],
@ -167,6 +173,20 @@ class _LayoutState extends State<Layout> {
), ),
], ],
), ),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"No Project Assigned",
fontWeight: 700,
color: Colors.redAccent,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
), ),
), ),
if (isBetaEnvironment) if (isBetaEnvironment)
@ -193,10 +213,10 @@ class _LayoutState extends State<Layout> {
), ),
), ),
/// Expanded Project List inside card // Expanded Project List inside card only show if projects exist
if (isExpanded) if (isExpanded && hasProjects)
Positioned( Positioned(
top: 70, // slightly below the row top: 70,
left: 0, left: 0,
right: 0, right: 0,
child: Container( child: Container(

View File

@ -50,8 +50,10 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
dailyTaskController.fetchTaskData(initialProjectId); dailyTaskController.fetchTaskData(initialProjectId);
} }
final selectedProjectIdRx = projectController.selectedProjectId;
if (selectedProjectIdRx != null) {
ever<String?>( ever<String?>(
projectController.selectedProjectId!, selectedProjectIdRx,
(newProjectId) async { (newProjectId) async {
if (newProjectId != null && if (newProjectId != null &&
newProjectId != dailyTaskController.selectedProjectId) { newProjectId != dailyTaskController.selectedProjectId) {
@ -61,6 +63,10 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
} }
}, },
); );
} else {
debugPrint(
"Warning: selectedProjectId is null, skipping listener setup.");
}
} }
@override @override
@ -133,6 +139,7 @@ class _DailyProgressReportScreenState extends State<DailyProgressReportScreen>
), ),
); );
} }
Widget _buildActionBar() { Widget _buildActionBar() {
return Padding( return Padding(
padding: MySpacing.x(flexSpacing), padding: MySpacing.x(flexSpacing),

View File

@ -31,18 +31,24 @@ class _DailyTaskPlaningScreenState extends State<DailyTaskPlaningScreen>
void initState() { void initState() {
super.initState(); super.initState();
// Initial fetch // Initial fetch if a project is already selected
final projectId = projectController.selectedProjectId?.value; final projectId = projectController.selectedProjectId?.value;
if (projectId != null) { if (projectId != null) {
dailyTaskPlaningController.fetchTaskData(projectId); dailyTaskPlaningController.fetchTaskData(projectId);
} }
// Reactive fetch on project ID change // Reactive fetch on project ID change
ever<String?>(projectController.selectedProjectId!, (projectId) { final selectedProject = projectController.selectedProjectId;
if (projectId != null) { if (selectedProject != null) {
dailyTaskPlaningController.fetchTaskData(projectId); ever<String?>(
selectedProject,
(newProjectId) {
if (newProjectId != null) {
dailyTaskPlaningController.fetchTaskData(newProjectId);
}
},
);
} }
});
} }
@override @override

View File

@ -69,6 +69,7 @@ dependencies:
path: ^1.9.0 path: ^1.9.0
percent_indicator: ^4.2.2 percent_indicator: ^4.2.2
flutter_contacts: ^1.1.9+2 flutter_contacts: ^1.1.9+2
photo_view: ^0.15.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter