539 lines
25 KiB
Dart
539 lines
25 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:marco/helpers/theme/app_theme.dart';
|
|
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
|
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/controller/task_Planning/daily_task_Planning_controller.dart';
|
|
import 'package:marco/controller/project_controller.dart';
|
|
import 'package:percent_indicator/percent_indicator.dart';
|
|
import 'package:marco/model/dailyTaskPlanning/assign_task_bottom_sheet .dart';
|
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
|
import 'package:marco/helpers/utils/permission_constants.dart';
|
|
import 'package:marco/helpers/widgets/my_refresh_indicator.dart';
|
|
import 'package:marco/controller/tenant/service_controller.dart';
|
|
import 'package:marco/helpers/widgets/tenant/service_selector.dart';
|
|
|
|
class DailyTaskPlanningScreen extends StatefulWidget {
|
|
DailyTaskPlanningScreen({super.key});
|
|
|
|
@override
|
|
State<DailyTaskPlanningScreen> createState() =>
|
|
_DailyTaskPlanningScreenState();
|
|
}
|
|
|
|
class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|
with UIMixin {
|
|
final DailyTaskPlanningController dailyTaskPlanningController =
|
|
Get.put(DailyTaskPlanningController());
|
|
final PermissionController permissionController =
|
|
Get.put(PermissionController());
|
|
final ProjectController projectController = Get.find<ProjectController>();
|
|
final ServiceController serviceController = Get.put(ServiceController());
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
final projectId = projectController.selectedProjectId.value;
|
|
if (projectId.isNotEmpty) {
|
|
// Now this will fetch only services + building list (no deep infra)
|
|
dailyTaskPlanningController.fetchTaskData(projectId);
|
|
serviceController.fetchServices(projectId);
|
|
}
|
|
|
|
// Whenever project changes, fetch buildings & services (still lazy load infra per building)
|
|
ever<String>(
|
|
projectController.selectedProjectId,
|
|
(newProjectId) {
|
|
if (newProjectId.isNotEmpty) {
|
|
dailyTaskPlanningController.fetchTaskData(newProjectId);
|
|
serviceController.fetchServices(newProjectId);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: PreferredSize(
|
|
preferredSize: const Size.fromHeight(72),
|
|
child: AppBar(
|
|
backgroundColor: const Color(0xFFF5F5F5),
|
|
elevation: 0.5,
|
|
automaticallyImplyLeading: false,
|
|
titleSpacing: 0,
|
|
title: Padding(
|
|
padding: MySpacing.xy(16, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.arrow_back_ios_new,
|
|
color: Colors.black, size: 20),
|
|
onPressed: () => Get.offNamed('/dashboard'),
|
|
),
|
|
MySpacing.width(8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
MyText.titleLarge(
|
|
'Daily Task Planning',
|
|
fontWeight: 700,
|
|
color: Colors.black,
|
|
),
|
|
MySpacing.height(2),
|
|
GetBuilder<ProjectController>(builder: (projectController) {
|
|
final projectName =
|
|
projectController.selectedProject?.name ?? 'Select Project';
|
|
return Row(
|
|
children: [
|
|
const Icon(Icons.work_outline, size: 14, color: Colors.grey),
|
|
MySpacing.width(4),
|
|
Expanded(
|
|
child: MyText.bodySmall(
|
|
projectName,
|
|
fontWeight: 600,
|
|
overflow: TextOverflow.ellipsis,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
body: SafeArea(
|
|
child: MyRefreshIndicator(
|
|
onRefresh: () async {
|
|
final projectId = projectController.selectedProjectId.value;
|
|
if (projectId.isNotEmpty) {
|
|
try {
|
|
// keep previous behavior but now fetchTaskData is lighter (buildings only)
|
|
await dailyTaskPlanningController.fetchTaskData(
|
|
projectId,
|
|
serviceId: serviceController.selectedService?.id,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Error refreshing task data: ${e.toString()}');
|
|
}
|
|
}
|
|
},
|
|
child: SingleChildScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: MySpacing.x(0),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: MediaQuery.of(context).size.height -
|
|
kToolbarHeight -
|
|
MediaQuery.of(context).padding.top,
|
|
),
|
|
child: GetBuilder<DailyTaskPlanningController>(
|
|
init: dailyTaskPlanningController,
|
|
tag: 'daily_task_Planning_controller',
|
|
builder: (controller) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MySpacing.height(flexSpacing),
|
|
Padding(
|
|
padding: MySpacing.x(10),
|
|
child: ServiceSelector(
|
|
controller: serviceController,
|
|
height: 40,
|
|
onSelectionChanged: (service) async {
|
|
final projectId =
|
|
projectController.selectedProjectId.value;
|
|
if (projectId.isNotEmpty) {
|
|
await dailyTaskPlanningController.fetchTaskData(
|
|
projectId,
|
|
serviceId:
|
|
service?.id, // <-- pass selected service
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
MySpacing.height(flexSpacing),
|
|
Padding(
|
|
padding: MySpacing.x(8),
|
|
child: dailyProgressReportTab(),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget dailyProgressReportTab() {
|
|
return Obx(() {
|
|
final isLoading = dailyTaskPlanningController.isFetchingTasks.value;
|
|
final dailyTasks = dailyTaskPlanningController.dailyTasks;
|
|
|
|
if (isLoading) {
|
|
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
|
|
}
|
|
|
|
if (dailyTasks.isEmpty) {
|
|
return Center(
|
|
child: MyText.bodySmall(
|
|
"No Progress Report Found",
|
|
fontWeight: 600,
|
|
),
|
|
);
|
|
}
|
|
|
|
final buildingExpansionState = <String, bool>{};
|
|
final floorExpansionState = <String, bool>{};
|
|
|
|
Widget buildExpandIcon(bool isExpanded) {
|
|
return Container(
|
|
width: 32,
|
|
height: 32,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.grey.shade200,
|
|
),
|
|
child: Icon(
|
|
isExpanded ? Icons.remove : Icons.add,
|
|
size: 20,
|
|
color: Colors.black87,
|
|
),
|
|
);
|
|
}
|
|
|
|
return StatefulBuilder(builder: (context, setMainState) {
|
|
// Show all buildings (they may or may not have floors loaded yet)
|
|
final buildings = dailyTasks.expand((task) => task.buildings).toList();
|
|
|
|
if (buildings.isEmpty) {
|
|
return Center(
|
|
child: MyText.bodySmall(
|
|
"No Progress Report Found",
|
|
fontWeight: 600,
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: buildings.map((building) {
|
|
final buildingKey = building.id.toString();
|
|
final isBuildingExpanded = buildingExpansionState[buildingKey] ?? false;
|
|
final buildingLoading = dailyTaskPlanningController.buildingLoadingStates[buildingKey]?.value ?? false;
|
|
final buildingLoaded = dailyTaskPlanningController.buildingsWithDetails.contains(buildingKey);
|
|
|
|
return MyCard.bordered(
|
|
borderRadiusAll: 10,
|
|
paddingAll: 0,
|
|
margin: MySpacing.bottom(10),
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
|
child: ExpansionTile(
|
|
onExpansionChanged: (expanded) async {
|
|
setMainState(() {
|
|
buildingExpansionState[buildingKey] = expanded;
|
|
});
|
|
|
|
if (expanded && !buildingLoaded && !buildingLoading) {
|
|
// fetch infra details for this building lazily
|
|
final projectId = projectController.selectedProjectId.value;
|
|
if (projectId.isNotEmpty) {
|
|
await dailyTaskPlanningController.fetchBuildingInfra(
|
|
building.id.toString(),
|
|
projectId,
|
|
serviceController.selectedService?.id,
|
|
);
|
|
setMainState(() {}); // rebuild to reflect loaded data
|
|
}
|
|
}
|
|
},
|
|
trailing: buildExpandIcon(isBuildingExpanded),
|
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
|
collapsedShape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
leading: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.blueAccent.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
padding: const EdgeInsets.all(8),
|
|
child: Icon(
|
|
Icons.location_city_rounded,
|
|
color: Colors.blueAccent,
|
|
size: 24,
|
|
),
|
|
),
|
|
title: MyText.titleMedium(
|
|
building.name,
|
|
fontWeight: 700,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
childrenPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
|
children: [
|
|
if (buildingLoading)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Center(
|
|
child: SizedBox(
|
|
height: 24,
|
|
width: 24,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
)
|
|
else if (!buildingLoaded || building.floors.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: MyText.bodySmall(
|
|
"No Progress Report Found",
|
|
fontWeight: 600,
|
|
),
|
|
)
|
|
else
|
|
// Building is loaded and has floors; render floors -> areas -> items
|
|
Column(
|
|
children: building.floors.expand((floor) {
|
|
final validWorkAreas = floor.workAreas.where((area) => area.workItems.isNotEmpty);
|
|
|
|
return validWorkAreas.map((area) {
|
|
final floorWorkAreaKey =
|
|
"${buildingKey}_${floor.floorName}_${area.areaName}";
|
|
final isExpanded = floorExpansionState[floorWorkAreaKey] ?? false;
|
|
final workItems = area.workItems;
|
|
final totalPlanned = workItems.fold<double>(0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0));
|
|
final totalCompleted = workItems.fold<double>(0, (sum, wi) => sum + (wi.workItem.completedWork ?? 0));
|
|
final totalProgress = totalPlanned == 0 ? 0.0 : (totalCompleted / totalPlanned).clamp(0.0, 1.0);
|
|
|
|
return ExpansionTile(
|
|
onExpansionChanged: (expanded) {
|
|
setMainState(() {
|
|
floorExpansionState[floorWorkAreaKey] = expanded;
|
|
});
|
|
},
|
|
trailing: Icon(
|
|
isExpanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
|
size: 28,
|
|
color: Colors.black54,
|
|
),
|
|
tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
|
|
title: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.titleSmall(
|
|
"Floor: ${floor.floorName}",
|
|
fontWeight: 600,
|
|
color: Colors.teal,
|
|
maxLines: null,
|
|
overflow: TextOverflow.visible,
|
|
softWrap: true,
|
|
),
|
|
MySpacing.height(4),
|
|
MyText.titleSmall(
|
|
"Work Area: ${area.areaName}",
|
|
fontWeight: 600,
|
|
color: Colors.blueGrey,
|
|
maxLines: null,
|
|
overflow: TextOverflow.visible,
|
|
softWrap: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
MySpacing.width(12),
|
|
CircularPercentIndicator(
|
|
radius: 20.0,
|
|
lineWidth: 4.0,
|
|
animation: true,
|
|
percent: totalProgress,
|
|
center: Text(
|
|
"${(totalProgress * 100).toStringAsFixed(0)}%",
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 10.0,
|
|
),
|
|
),
|
|
circularStrokeCap: CircularStrokeCap.round,
|
|
progressColor: totalProgress >= 1.0
|
|
? Colors.green
|
|
: (totalProgress >= 0.5 ? Colors.amber : Colors.red),
|
|
backgroundColor: Colors.grey[300]!,
|
|
),
|
|
],
|
|
),
|
|
childrenPadding: const EdgeInsets.only(left: 16, right: 0, bottom: 8),
|
|
children: area.workItems.map((wItem) {
|
|
final item = wItem.workItem;
|
|
final completed = item.completedWork ?? 0;
|
|
final planned = item.plannedWork ?? 0;
|
|
final progress = (planned == 0) ? 0.0 : (completed / planned).clamp(0.0, 1.0);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: MyText.bodyMedium(
|
|
item.activityMaster?.name ?? "No Activity",
|
|
fontWeight: 600,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.visible,
|
|
softWrap: true,
|
|
),
|
|
),
|
|
MySpacing.width(8),
|
|
if (item.workCategoryMaster?.name != null)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade100,
|
|
borderRadius: BorderRadius.circular(20),
|
|
),
|
|
child: MyText.bodySmall(
|
|
item.workCategoryMaster!.name!,
|
|
fontWeight: 500,
|
|
color: Colors.blue.shade800,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
MySpacing.height(4),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
flex: 3,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MySpacing.height(8),
|
|
MyText.bodySmall(
|
|
"Completed: $completed / $planned",
|
|
fontWeight: 600,
|
|
color: const Color.fromARGB(221, 0, 0, 0),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
MySpacing.width(16),
|
|
if (progress < 1.0 &&
|
|
permissionController.hasPermission(Permissions.assignReportTask))
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.person_add_alt_1_rounded,
|
|
color: Color.fromARGB(255, 46, 161, 233),
|
|
),
|
|
onPressed: () {
|
|
final pendingTask = (planned - completed).clamp(0, planned).toInt();
|
|
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
builder: (context) => AssignTaskBottomSheet(
|
|
buildingName: building.name,
|
|
floorName: floor.floorName,
|
|
workAreaName: area.areaName,
|
|
workLocation: area.areaName,
|
|
activityName: item.activityMaster?.name ?? "Unknown Activity",
|
|
pendingTask: pendingTask,
|
|
workItemId: item.id.toString(),
|
|
assignmentDate: DateTime.now(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
MySpacing.height(6),
|
|
Row(
|
|
children: [
|
|
MyText.bodySmall(
|
|
"Today's Planned: ${item.todaysAssigned ?? 0}",
|
|
fontWeight: 600,
|
|
),
|
|
],
|
|
),
|
|
MySpacing.height(16),
|
|
Stack(
|
|
children: [
|
|
Container(
|
|
height: 5,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[300],
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
),
|
|
FractionallySizedBox(
|
|
widthFactor: progress,
|
|
child: Container(
|
|
height: 5,
|
|
decoration: BoxDecoration(
|
|
color: progress >= 1.0
|
|
? Colors.green
|
|
: (progress >= 0.5 ? Colors.amber : Colors.red),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
MyText.bodySmall(
|
|
"${(progress * 100).toStringAsFixed(1)}%",
|
|
fontWeight: 500,
|
|
color: progress >= 1.0
|
|
? Colors.green[700]
|
|
: (progress >= 0.5 ? Colors.amber[800] : Colors.red[700]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}).toList();
|
|
}).toList(),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
});
|
|
});
|
|
}
|
|
}
|