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

View File

@ -28,6 +28,19 @@ class ProjectController extends GetxController {
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.
Future<void> fetchProjects() async {
isLoadingProjects.value = true;
@ -62,6 +75,8 @@ class ProjectController extends GetxController {
Future<void> updateSelectedProject(String projectId) async {
selectedProjectId?.value = 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:image_picker/image_picker.dart';
import 'dart:io';
import 'dart:convert';
final Logger logger = Logger();
@ -96,6 +97,7 @@ class ReportTaskController extends MyController {
required int completedTask,
required List<Map<String, dynamic>> checklist,
required DateTime reportedDate,
List<File>? images,
}) async {
logger.i("Starting task report...");
@ -132,12 +134,24 @@ class ReportTaskController extends MyController {
try {
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(
id: projectId,
comment: commentField,
completedTask: completedTask,
completedTask: completedWorkInt,
checkList: checklist,
images: imageData,
);
if (success) {
@ -169,6 +183,7 @@ class ReportTaskController extends MyController {
Future<void> commentTask({
required String projectId,
required String comment,
List<File>? images,
}) async {
logger.i("Starting task comment...");
@ -184,10 +199,22 @@ class ReportTaskController extends MyController {
try {
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(
id: projectId,
comment: commentField,
images: imageData,
);
if (success) {

View File

@ -343,6 +343,7 @@ class ApiService {
required int completedTask,
required String comment,
required List<Map<String, dynamic>> checkList,
List<Map<String, dynamic>>? images,
}) async {
final body = {
"id": id,
@ -350,6 +351,7 @@ class ApiService {
"comment": comment,
"reportedDate": DateTime.now().toUtc().toIso8601String(),
"checkList": checkList,
if (images != null && images.isNotEmpty) "images": images,
};
final response = await _postRequest(ApiEndpoints.reportTask, body);
@ -373,11 +375,13 @@ class ApiService {
static Future<bool> commentTask({
required String id,
required String comment,
List<Map<String, dynamic>>? images,
}) async {
final body = {
"taskAllocationId": id,
"comment": comment,
"commentDate": DateTime.now().toUtc().toIso8601String(),
if (images != null && images.isNotEmpty) "images": images,
};
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:logger/logger.dart';
import 'package:marco/helpers/services/api_endpoints.dart';
import 'package:marco/controller/project_controller.dart';
final Logger logger = Logger();
@ -369,7 +370,8 @@ class AuthService {
// Put and load PermissionController
final permissionController = Get.put(PermissionController());
await permissionController.loadData(jwtToken);
await permissionController.loadData(jwtToken);
await Get.find<ProjectController>().fetchProjects();
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/employee_info.dart';
import 'dart:convert';
import 'package:get/route_manager.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:get/get.dart';
class LocalStorage {
static const String _loggedInUserKey = "user";
@ -134,20 +136,25 @@ class LocalStorage {
return setToken(_refreshTokenKey, refreshToken);
}
static Future<void> logout() async {
await removeLoggedInUser();
await removeToken(_jwtTokenKey);
await removeToken(_refreshTokenKey);
await removeUserPermissions();
await removeEmployeeInfo();
await removeMpinToken();
await removeIsMpin();
await preferences.remove("mpin_verified");
await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey);
Get.offAllNamed('/auth/login-option');
static Future<void> logout() async {
await removeLoggedInUser();
await removeToken(_jwtTokenKey);
await removeToken(_refreshTokenKey);
await removeUserPermissions();
await removeEmployeeInfo();
await removeMpinToken();
await removeIsMpin();
await preferences.remove("mpin_verified");
await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId');
if (Get.isRegistered<ProjectController>()) {
Get.find<ProjectController>().clearProjects();
}
Get.offAllNamed('/auth/login-option');
}
static Future<bool> setMpinToken(String token) {
return preferences.setString(_mpinTokenKey, token);
}

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/my_team_model_sheet.dart';
import 'package:intl/intl.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
class CommentTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
@ -206,6 +207,148 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
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),
Row(
mainAxisAlignment: MainAxisAlignment.end,
@ -233,7 +376,9 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
.getController('comment')
?.text ??
'',
images: controller.selectedImages,
);
if (widget.onCommentSuccess != null) {
widget.onCommentSuccess!();
}
@ -262,12 +407,13 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
}),
],
),
MySpacing.height(24),
MySpacing.height(10),
if ((widget.taskData['taskComments'] as List<dynamic>?)
?.isNotEmpty ==
true) ...[
Row(
children: [
MySpacing.width(10),
Icon(Icons.chat_bubble_outline,
size: 18, color: Colors.grey[700]),
MySpacing.width(8),
@ -277,6 +423,7 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
),
],
),
Divider(),
MySpacing.height(12),
Builder(
builder: (context) {
@ -298,84 +445,236 @@ class _CommentTaskBottomSheetState extends State<CommentTaskBottomSheet>
return SizedBox(
height: 300,
child: ListView.builder(
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText = comment['text'] ?? '-';
final commentedBy =
comment['commentedBy'] ?? 'Unknown';
final relativeTime =
timeAgo(comment['date'] ?? '');
itemCount: comments.length,
itemBuilder: (context, index) {
final comment = comments[index];
final commentText =
comment['text'] ?? '-';
final commentedBy =
comment['commentedBy'] ?? 'Unknown';
final relativeTime =
timeAgo(comment['date'] ?? '');
return Container(
margin: EdgeInsets.symmetric(
vertical: 6, horizontal: 8),
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// Avatar for commenter
Avatar(
firstName:
commentedBy.split(' ').first,
lastName: commentedBy
.split(' ')
.length >
1
? commentedBy.split(' ').last
: '',
size: 32,
),
SizedBox(width: 12),
// Comment text and meta
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
Text(
commentedBy,
style: TextStyle(
fontWeight:
FontWeight.bold,
color: Colors.black87,
// 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(
margin: EdgeInsets.symmetric(
vertical: 0, horizontal: 0),
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius:
BorderRadius.circular(12),
),
child: Row(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
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(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
MyText.bodyMedium(
commentText,
fontWeight: 500,
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,
),
]),
SizedBox(height: 8),
SizedBox(
height: 60,
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),
),
],
),
child:
ClipRRect(
borderRadius:
BorderRadius.circular(
12),
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),
),
),
Text(
relativeTime,
style: TextStyle(
fontSize: 12,
color: Colors.black54,
),
)
SizedBox(height: 12),
],
),
SizedBox(height: 6),
Text(
commentText,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Colors.black87,
),
),
],
],
),
),
),
],
),
);
},
),
],
),
);
}),
);
},
),

View File

@ -10,8 +10,12 @@ import 'package:marco/helpers/widgets/my_text_style.dart';
class ReportTaskBottomSheet extends StatefulWidget {
final Map<String, dynamic> taskData;
final VoidCallback? onReportSuccess;
const ReportTaskBottomSheet({super.key, required this.taskData,this.onReportSuccess,});
final VoidCallback? onReportSuccess;
const ReportTaskBottomSheet({
super.key,
required this.taskData,
this.onReportSuccess,
});
@override
State<ReportTaskBottomSheet> createState() => _ReportTaskBottomSheetState();
@ -201,6 +205,147 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
),
),
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(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -237,6 +382,7 @@ class _ReportTaskBottomSheetState extends State<ReportTaskBottomSheet>
0,
checklist: [],
reportedDate: DateTime.now(),
images: controller.selectedImages,
);
if (widget.onReportSuccess != null) {
widget.onReportSuccess!();

View File

@ -236,8 +236,17 @@ class _MPINAuthScreenState extends State<MPINAuthScreen> with UIMixin {
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) =>
controller.onDigitChanged(value, index, isRetype: isRetype),
onChanged: (value) {
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(
counterText: '',
filled: true,

View File

@ -36,19 +36,27 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
@override
void initState() {
super.initState();
final projectController = Get.find<ProjectController>();
ever<String?>(projectController.selectedProjectId!, (projectId) async {
if (projectId != null && projectId.isNotEmpty) {
try {
await attendanceController.fetchEmployeesByProject(projectId);
await attendanceController.fetchAttendanceLogs(projectId);
await attendanceController.fetchRegularizationLogs(projectId);
await attendanceController.fetchProjectData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error updating data on project change: $e");
}
}
final attendanceController = Get.find<AttendanceController>();
WidgetsBinding.instance.addPostFrameCallback((_) {
ever<String?>(
projectController.selectedProjectId!,
(projectId) async {
if (projectId != null && projectId.isNotEmpty) {
try {
await attendanceController.fetchEmployeesByProject(projectId);
await attendanceController.fetchAttendanceLogs(projectId);
await attendanceController.fetchRegularizationLogs(projectId);
await attendanceController.fetchProjectData(projectId);
attendanceController.update(['attendance_dashboard_controller']);
} catch (e) {
debugPrint("Error updating data on project change: $e");
}
}
},
);
});
}
@ -107,6 +115,12 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
init: attendanceController,
tag: 'attendance_dashboard_controller',
builder: (controller) {
final selectedProjectId =
Get.find<ProjectController>().selectedProjectId?.value;
final bool noProjectSelected =
selectedProjectId == null || selectedProjectId.isEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -234,11 +248,22 @@ class _AttendanceScreenState extends State<AttendanceScreen> with UIMixin {
MyFlex(children: [
MyFlexItem(
sizes: 'lg-12 md-12 sm-12',
child: selectedTab == 'todaysAttendance'
? employeeListTab()
: selectedTab == 'attendanceLogs'
? employeeLog()
: regularizationScreen(),
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()
: selectedTab == 'attendanceLogs'
? employeeLog()
: regularizationScreen(),
),
]),
],

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_text.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/controller/dashboard/employees_screen_controller.dart';
import 'package:marco/helpers/widgets/avatar.dart';
import 'package:marco/model/employees/employee_detail_bottom_sheet.dart';
import 'package:marco/controller/project_controller.dart';
class EmployeesScreen extends StatefulWidget {
const EmployeesScreen({super.key});
@ -27,52 +27,45 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
Get.put(EmployeesScreenController());
final PermissionController permissionController =
Get.put(PermissionController());
Future<void> _refreshEmployees() async {
try {
final selectedProjectId =
Get.find<ProjectController>().selectedProject?.id;
final isAllSelected =
employeeScreenController.isAllEmployeeSelected.value;
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) {
if (isAllSelected) {
employeeScreenController.selectedProjectId = null;
await employeeScreenController.fetchAllEmployees();
} else if (selectedProjectId != null) {
employeeScreenController.selectedProjectId = selectedProjectId;
try {
if (selectedProjectId == null) {
await employeeScreenController.fetchAllEmployees();
} else {
await employeeScreenController
.fetchEmployeesByProject(selectedProjectId);
}
} catch (e) {
debugPrint('Error fetching employees: ${e.toString()}');
}
employeeScreenController.update(['employee_screen_controller']);
await employeeScreenController
.fetchEmployeesByProject(selectedProjectId);
} else {
// Clear employees if neither selected
employeeScreenController.clearEmployees();
}
employeeScreenController.update(['employee_screen_controller']);
} catch (e, stackTrace) {
debugPrint('Error refreshing employee data: ${e.toString()}');
debugPrintStack(stackTrace: stackTrace);
}
}
Future<void> _refreshEmployees() async {
try {
final projectId = employeeScreenController.selectedProjectId;
if (projectId == null) {
await employeeScreenController.fetchAllEmployees();
} else {
await employeeScreenController.fetchEmployeesByProject(projectId);
}
} catch (e) {
debugPrint('Error refreshing employee data: ${e.toString()}');
@override
void initState() {
super.initState();
final selectedProjectId = Get.find<ProjectController>().selectedProject?.id;
if (selectedProjectId != null) {
employeeScreenController.selectedProjectId = selectedProjectId;
employeeScreenController.fetchEmployeesByProject(selectedProjectId);
} else if (employeeScreenController.isAllEmployeeSelected.value) {
employeeScreenController.selectedProjectId = null;
employeeScreenController.fetchAllEmployees();
} else {
employeeScreenController.clearEmployees();
}
}
@ -194,26 +187,66 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
MyText.bodyMedium("Filter", fontWeight: 600),
Tooltip(
message: 'Project',
child: InkWell(
borderRadius: BorderRadius.circular(24),
onTap: _openFilterSheet,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(
Icons.filter_list_alt,
color: Colors.blueAccent,
size: 28,
Obx(() {
return Row(
children: [
Checkbox(
value: employeeScreenController
.isAllEmployeeSelected.value,
activeColor: Colors.blueAccent,
fillColor:
MaterialStateProperty.resolveWith<Color?>(
(states) {
if (states.contains(MaterialState.selected)) {
return Colors.blueAccent;
}
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']);
},
),
),
),
),
const SizedBox(width: 8),
MyText.bodyMedium(
"All Employees",
fontWeight: 600,
),
],
);
}),
const SizedBox(width: 16),
MyText.bodyMedium("Refresh", fontWeight: 600),
Tooltip(
message: 'Refresh Data',
@ -253,16 +286,17 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
return Obx(() {
final isLoading = employeeScreenController.isLoading.value;
final employees = employeeScreenController.employees;
if (isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (employees.isEmpty) {
return Center(
child: MyText.bodySmall(
"No Assigned Employees Found",
fontWeight: 600,
return Padding(
padding: const EdgeInsets.only(top: 50),
child: Center(
child: MyText.bodySmall(
"No Assigned Employees Found",
fontWeight: 600,
),
),
);
}

View File

@ -100,7 +100,11 @@ class _LayoutState extends State<Layout> {
(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
.updateSelectedProject(projectController.projects.first.id);
}
@ -111,7 +115,7 @@ class _LayoutState extends State<Layout> {
borderRadius: BorderRadius.circular(12),
),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias, // important for overlap inside card
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Padding(
@ -129,45 +133,61 @@ class _LayoutState extends State<Layout> {
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => projectController
.isProjectSelectionExpanded
.toggle(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Row(
child: hasProjects
? GestureDetector(
onTap: () => projectController
.isProjectSelectionExpanded
.toggle(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: MyText.bodyLarge(
selectedProject?.name ??
"Select Project",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: Row(
children: [
Expanded(
child: MyText.bodyLarge(
selectedProject?.name ??
"Select Project",
fontWeight: 700,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(
isExpanded
? Icons.arrow_drop_up_outlined
: Icons
.arrow_drop_down_outlined,
color: Colors.black,
),
],
),
),
Icon(
isExpanded
? Icons.arrow_drop_up_outlined
: Icons.arrow_drop_down_outlined,
color: Colors.black,
),
],
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyLarge(
"No Project Assigned",
fontWeight: 700,
color: Colors.redAccent,
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
MyText.bodyMedium(
"Hi, ${employeeInfo?.firstName ?? ''}",
color: Colors.black54,
),
],
),
),
),
if (isBetaEnvironment)
Container(
@ -193,10 +213,10 @@ class _LayoutState extends State<Layout> {
),
),
/// Expanded Project List inside card
if (isExpanded)
// Expanded Project List inside card only show if projects exist
if (isExpanded && hasProjects)
Positioned(
top: 70, // slightly below the row
top: 70,
left: 0,
right: 0,
child: Container(

View File

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

View File

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

View File

@ -182,7 +182,7 @@ class _ReportTaskScreenState extends State<ReportTaskScreen> with UIMixin {
),
),
MySpacing.height(24),
// Buttons
Row(
mainAxisAlignment: MainAxisAlignment.end,

View File

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