diff --git a/lib/controller/task_planning/daily_task_planning_controller.dart b/lib/controller/task_planning/daily_task_planning_controller.dart index 3b02beb..24217d5 100644 --- a/lib/controller/task_planning/daily_task_planning_controller.dart +++ b/lib/controller/task_planning/daily_task_planning_controller.dart @@ -23,6 +23,10 @@ class DailyTaskPlanningController extends GetxController { RxBool isFetchingProjects = true.obs; RxBool isFetchingEmployees = true.obs; + /// New: track per-building loading and loaded state for lazy infra loading + RxMap buildingLoadingStates = {}.obs; + final Set buildingsWithDetails = {}; + @override void onInit() { super.onInit(); @@ -106,7 +110,7 @@ class DailyTaskPlanningController extends GetxController { } } - /// Fetch Infra details and then tasks per work area + /// Fetch buildings list only (no deep area/workItem calls) for initial load. Future fetchTaskData(String? projectId, {String? serviceId}) async { if (projectId == null) return; @@ -123,24 +127,13 @@ class DailyTaskPlanningController extends GetxController { return; } + // Build only building-level data (floors empty). This enables lazy loading later. dailyTasks = infraData.map((buildingJson) { final building = Building( id: buildingJson['id'], name: buildingJson['buildingName'], description: buildingJson['description'], - floors: (buildingJson['floors'] as List) - .map((floorJson) => Floor( - id: floorJson['id'], - floorName: floorJson['floorName'], - workAreas: (floorJson['workAreas'] as List) - .map((areaJson) => WorkArea( - id: areaJson['id'], - areaName: areaJson['areaName'], - workItems: [], - )) - .toList(), - )) - .toList(), + floors: [], // don't populate floors here - lazy load per-building ); return TaskPlanningDetailsModel( id: building.id, @@ -154,11 +147,65 @@ class DailyTaskPlanningController extends GetxController { ); }).toList(); - await Future.wait(dailyTasks - .expand((task) => task.buildings) - .expand((b) => b.floors) - .expand((f) => f.workAreas) - .map((area) async { + // Reset building loaded/loading maps because we replaced the list + buildingLoadingStates.clear(); + buildingsWithDetails.clear(); + } catch (e, stack) { + logSafe("Error fetching daily task data", + level: LogLevel.error, error: e, stackTrace: stack); + } finally { + isFetchingTasks.value = false; + update(); + } + } + + /// Fetch full infra for a single building (floors, workAreas, workItems). + /// Called lazily when user expands a building in the UI. + Future fetchBuildingInfra(String buildingId, String projectId, String? serviceId) async { + if (buildingId.isEmpty) return; + + // mark loading + buildingLoadingStates.putIfAbsent(buildingId, () => true.obs); + buildingLoadingStates[buildingId]!.value = true; + update(); + + try { + // Re-use getInfraDetails and find the building entry for the requested buildingId + final infraResponse = await ApiService.getInfraDetails(projectId, serviceId: serviceId); + final infraData = infraResponse?['data'] as List? ?? []; + + final buildingJson = infraData.firstWhere( + (b) => b['id'].toString() == buildingId.toString(), + orElse: () => null, + ); + + if (buildingJson == null) { + logSafe("Building $buildingId not found in infra response", level: LogLevel.warning); + return; + } + + // Build floors & workAreas for this building + final building = Building( + id: buildingJson['id'], + name: buildingJson['buildingName'], + description: buildingJson['description'], + floors: (buildingJson['floors'] as List? ?? []).map((floorJson) { + return Floor( + id: floorJson['id'], + floorName: floorJson['floorName'], + workAreas: (floorJson['workAreas'] as List? ?? []).map((areaJson) { + return WorkArea( + id: areaJson['id'], + areaName: areaJson['areaName'], + workItems: [], // will populate below + ); + }).toList(), + ); + }).toList(), + ); + + // For each workArea, fetch its work items and populate + await Future.wait(building.floors.expand((f) => f.workAreas).map((area) async { try { final taskResponse = await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId); @@ -177,9 +224,7 @@ class DailyTaskPlanningController extends GetxController { completedWork: (taskJson['completedWork'] as num?)?.toDouble(), todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(), description: taskJson['description'] as String?, - taskDate: taskJson['taskDate'] != null - ? DateTime.tryParse(taskJson['taskDate']) - : null, + taskDate: taskJson['taskDate'] != null ? DateTime.tryParse(taskJson['taskDate']) : null, ), ))); } catch (e, stack) { @@ -187,11 +232,39 @@ class DailyTaskPlanningController extends GetxController { level: LogLevel.error, error: e, stackTrace: stack); } })); + + // Merge/replace the building into dailyTasks + bool merged = false; + for (var t in dailyTasks) { + final idx = t.buildings.indexWhere((b) => b.id.toString() == building.id.toString()); + if (idx != -1) { + t.buildings[idx] = building; + merged = true; + break; + } + } + if (!merged) { + // If not present, add a new TaskPlanningDetailsModel wrapper (fallback) + dailyTasks.add(TaskPlanningDetailsModel( + id: building.id, + name: building.name, + projectAddress: "", + contactPerson: "", + startDate: DateTime.now(), + endDate: DateTime.now(), + projectStatusId: "", + buildings: [building], + )); + } + + // Mark as loaded + buildingsWithDetails.add(buildingId.toString()); } catch (e, stack) { - logSafe("Error fetching daily task data", + logSafe("Error fetching infra for building $buildingId", level: LogLevel.error, error: e, stackTrace: stack); } finally { - isFetchingTasks.value = false; + buildingLoadingStates.putIfAbsent(buildingId, () => false.obs); + buildingLoadingStates[buildingId]!.value = false; update(); } } diff --git a/lib/view/taskPlanning/daily_task_planning.dart b/lib/view/taskPlanning/daily_task_planning.dart index dd28f52..e45ae50 100644 --- a/lib/view/taskPlanning/daily_task_planning.dart +++ b/lib/view/taskPlanning/daily_task_planning.dart @@ -39,11 +39,12 @@ class _DailyTaskPlanningScreenState extends State 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 tasks & services + // Whenever project changes, fetch buildings & services (still lazy load infra per building) ever( projectController.selectedProjectId, (newProjectId) { @@ -87,28 +88,24 @@ class _DailyTaskPlanningScreenState extends State color: Colors.black, ), MySpacing.height(2), - GetBuilder( - 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], - ), + GetBuilder(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], ), - ], - ); - }, - ), + ), + ], + ); + }), ], ), ), @@ -123,6 +120,7 @@ class _DailyTaskPlanningScreenState extends State 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, @@ -221,14 +219,10 @@ class _DailyTaskPlanningScreenState extends State } return StatefulBuilder(builder: (context, setMainState) { - final filteredBuildings = dailyTasks.expand((task) { - return task.buildings.where((building) { - return building.floors.any((floor) => - floor.workAreas.any((area) => area.workItems.isNotEmpty)); - }); - }).toList(); + // Show all buildings (they may or may not have floors loaded yet) + final buildings = dailyTasks.expand((task) => task.buildings).toList(); - if (filteredBuildings.isEmpty) { + if (buildings.isEmpty) { return Center( child: MyText.bodySmall( "No Progress Report Found", @@ -239,26 +233,39 @@ class _DailyTaskPlanningScreenState extends State return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: filteredBuildings.map((building) { + 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), + data: Theme.of(context).copyWith(dividerColor: Colors.transparent), child: ExpansionTile( - onExpansionChanged: (expanded) { + 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( - buildingExpansionState[buildingKey] ?? false), - tilePadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 0), + trailing: buildExpandIcon(isBuildingExpanded), + tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0), collapsedShape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -283,255 +290,243 @@ class _DailyTaskPlanningScreenState extends State maxLines: 1, overflow: TextOverflow.ellipsis, ), - childrenPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 0), - 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( - 0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0)); - final totalCompleted = workItems.fold(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, + 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), + ), ), - tilePadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 0), - title: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - flex: 3, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + ) + 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(0, (sum, wi) => sum + (wi.workItem.plannedWork ?? 0)); + final totalCompleted = workItems.fold(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: [ - MyText.titleSmall( - "Floor: ${floor.floorName}", - fontWeight: 600, - color: Colors.teal, - maxLines: null, - overflow: TextOverflow.visible, - softWrap: true, + 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.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]!, ), ], ), - ), - 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); + 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, + 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: [ - MySpacing.height(8), MyText.bodySmall( - "Completed: $completed / $planned", + "Today's Planned: ${item.todaysAssigned ?? 0}", 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)), + MySpacing.height(16), + Stack( + children: [ + Container( + height: 5, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(6), ), - 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(), + ), + 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), + ), ), - ); - }, + ), + ], ), - ], - ), - - 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), + 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]), ), - ), - 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(); - }).toList(), + ) + ], ), ), );