feat: Implement lazy loading for building infrastructure and enhance task fetching logic

This commit is contained in:
Vaibhav Surve 2025-11-04 17:07:54 +05:30
parent 87520d7664
commit 99166801da
2 changed files with 360 additions and 292 deletions

View File

@ -23,6 +23,10 @@ class DailyTaskPlanningController extends GetxController {
RxBool isFetchingProjects = true.obs; RxBool isFetchingProjects = true.obs;
RxBool isFetchingEmployees = true.obs; RxBool isFetchingEmployees = true.obs;
/// New: track per-building loading and loaded state for lazy infra loading
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
final Set<String> buildingsWithDetails = <String>{};
@override @override
void onInit() { void onInit() {
super.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<void> fetchTaskData(String? projectId, {String? serviceId}) async { Future<void> fetchTaskData(String? projectId, {String? serviceId}) async {
if (projectId == null) return; if (projectId == null) return;
@ -123,24 +127,13 @@ class DailyTaskPlanningController extends GetxController {
return; return;
} }
// Build only building-level data (floors empty). This enables lazy loading later.
dailyTasks = infraData.map((buildingJson) { dailyTasks = infraData.map((buildingJson) {
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
description: buildingJson['description'], description: buildingJson['description'],
floors: (buildingJson['floors'] as List<dynamic>) floors: [], // don't populate floors here - lazy load per-building
.map((floorJson) => Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas: (floorJson['workAreas'] as List<dynamic>)
.map((areaJson) => WorkArea(
id: areaJson['id'],
areaName: areaJson['areaName'],
workItems: [],
))
.toList(),
))
.toList(),
); );
return TaskPlanningDetailsModel( return TaskPlanningDetailsModel(
id: building.id, id: building.id,
@ -154,11 +147,65 @@ class DailyTaskPlanningController extends GetxController {
); );
}).toList(); }).toList();
await Future.wait(dailyTasks // Reset building loaded/loading maps because we replaced the list
.expand((task) => task.buildings) buildingLoadingStates.clear();
.expand((b) => b.floors) buildingsWithDetails.clear();
.expand((f) => f.workAreas) } catch (e, stack) {
.map((area) async { 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<void> 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<dynamic>? ?? [];
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<dynamic>? ?? []).map((floorJson) {
return Floor(
id: floorJson['id'],
floorName: floorJson['floorName'],
workAreas: (floorJson['workAreas'] as List<dynamic>? ?? []).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 { try {
final taskResponse = final taskResponse =
await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId); await ApiService.getWorkItemsByWorkArea(area.id, serviceId: serviceId);
@ -177,9 +224,7 @@ class DailyTaskPlanningController extends GetxController {
completedWork: (taskJson['completedWork'] as num?)?.toDouble(), completedWork: (taskJson['completedWork'] as num?)?.toDouble(),
todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(), todaysAssigned: (taskJson['todaysAssigned'] as num?)?.toDouble(),
description: taskJson['description'] as String?, description: taskJson['description'] as String?,
taskDate: taskJson['taskDate'] != null taskDate: taskJson['taskDate'] != null ? DateTime.tryParse(taskJson['taskDate']) : null,
? DateTime.tryParse(taskJson['taskDate'])
: null,
), ),
))); )));
} catch (e, stack) { } catch (e, stack) {
@ -187,11 +232,39 @@ class DailyTaskPlanningController extends GetxController {
level: LogLevel.error, error: e, stackTrace: stack); 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) { } catch (e, stack) {
logSafe("Error fetching daily task data", logSafe("Error fetching infra for building $buildingId",
level: LogLevel.error, error: e, stackTrace: stack); level: LogLevel.error, error: e, stackTrace: stack);
} finally { } finally {
isFetchingTasks.value = false; buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
buildingLoadingStates[buildingId]!.value = false;
update(); update();
} }
} }

View File

@ -39,11 +39,12 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
// Now this will fetch only services + building list (no deep infra)
dailyTaskPlanningController.fetchTaskData(projectId); dailyTaskPlanningController.fetchTaskData(projectId);
serviceController.fetchServices(projectId); serviceController.fetchServices(projectId);
} }
// Whenever project changes, fetch tasks & services // Whenever project changes, fetch buildings & services (still lazy load infra per building)
ever<String>( ever<String>(
projectController.selectedProjectId, projectController.selectedProjectId,
(newProjectId) { (newProjectId) {
@ -87,28 +88,24 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
color: Colors.black, color: Colors.black,
), ),
MySpacing.height(2), MySpacing.height(2),
GetBuilder<ProjectController>( GetBuilder<ProjectController>(builder: (projectController) {
builder: (projectController) { final projectName =
final projectName = projectController.selectedProject?.name ?? 'Select Project';
projectController.selectedProject?.name ?? return Row(
'Select Project'; children: [
return Row( const Icon(Icons.work_outline, size: 14, color: Colors.grey),
children: [ MySpacing.width(4),
const Icon(Icons.work_outline, Expanded(
size: 14, color: Colors.grey), child: MyText.bodySmall(
MySpacing.width(4), projectName,
Expanded( fontWeight: 600,
child: MyText.bodySmall( overflow: TextOverflow.ellipsis,
projectName, color: Colors.grey[700],
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
), ),
], ),
); ],
}, );
), }),
], ],
), ),
), ),
@ -123,6 +120,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
final projectId = projectController.selectedProjectId.value; final projectId = projectController.selectedProjectId.value;
if (projectId.isNotEmpty) { if (projectId.isNotEmpty) {
try { try {
// keep previous behavior but now fetchTaskData is lighter (buildings only)
await dailyTaskPlanningController.fetchTaskData( await dailyTaskPlanningController.fetchTaskData(
projectId, projectId,
serviceId: serviceController.selectedService?.id, serviceId: serviceController.selectedService?.id,
@ -221,14 +219,10 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
} }
return StatefulBuilder(builder: (context, setMainState) { return StatefulBuilder(builder: (context, setMainState) {
final filteredBuildings = dailyTasks.expand((task) { // Show all buildings (they may or may not have floors loaded yet)
return task.buildings.where((building) { final buildings = dailyTasks.expand((task) => task.buildings).toList();
return building.floors.any((floor) =>
floor.workAreas.any((area) => area.workItems.isNotEmpty));
});
}).toList();
if (filteredBuildings.isEmpty) { if (buildings.isEmpty) {
return Center( return Center(
child: MyText.bodySmall( child: MyText.bodySmall(
"No Progress Report Found", "No Progress Report Found",
@ -239,26 +233,39 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: filteredBuildings.map((building) { children: buildings.map((building) {
final buildingKey = building.id.toString(); 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( return MyCard.bordered(
borderRadiusAll: 10, borderRadiusAll: 10,
paddingAll: 0, paddingAll: 0,
margin: MySpacing.bottom(10), margin: MySpacing.bottom(10),
child: Theme( child: Theme(
data: Theme.of(context) data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
.copyWith(dividerColor: Colors.transparent),
child: ExpansionTile( child: ExpansionTile(
onExpansionChanged: (expanded) { onExpansionChanged: (expanded) async {
setMainState(() { setMainState(() {
buildingExpansionState[buildingKey] = expanded; 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( trailing: buildExpandIcon(isBuildingExpanded),
buildingExpansionState[buildingKey] ?? false), tilePadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
tilePadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
collapsedShape: RoundedRectangleBorder( collapsedShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@ -283,255 +290,243 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
childrenPadding: childrenPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 0),
const EdgeInsets.symmetric(horizontal: 16, vertical: 0), children: [
children: building.floors.expand((floor) { if (buildingLoading)
final validWorkAreas = floor.workAreas Padding(
.where((area) => area.workItems.isNotEmpty); padding: const EdgeInsets.all(16.0),
child: Center(
return validWorkAreas.map((area) { child: SizedBox(
final floorWorkAreaKey = height: 24,
"${buildingKey}_${floor.floorName}_${area.areaName}"; width: 24,
final isExpanded = child: CircularProgressIndicator(strokeWidth: 2),
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), else if (!buildingLoaded || building.floors.isEmpty)
title: Row( Padding(
crossAxisAlignment: CrossAxisAlignment.center, padding: const EdgeInsets.all(16.0),
children: [ child: MyText.bodySmall(
Expanded( "No Progress Report Found",
flex: 3, fontWeight: 600,
child: Column( ),
crossAxisAlignment: CrossAxisAlignment.start, )
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: [ children: [
MyText.titleSmall( Expanded(
"Floor: ${floor.floorName}", flex: 3,
fontWeight: 600, child: Column(
color: Colors.teal, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: null, children: [
overflow: TextOverflow.visible, MyText.titleSmall(
softWrap: true, "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), MySpacing.width(12),
MyText.titleSmall( CircularPercentIndicator(
"Work Area: ${area.areaName}", radius: 20.0,
fontWeight: 600, lineWidth: 4.0,
color: Colors.blueGrey, animation: true,
maxLines: null, percent: totalProgress,
overflow: TextOverflow.visible, center: Text(
softWrap: true, "${(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),
MySpacing.width(12), children: area.workItems.map((wItem) {
CircularPercentIndicator( final item = wItem.workItem;
radius: 20.0, final completed = item.completedWork ?? 0;
lineWidth: 4.0, final planned = item.plannedWork ?? 0;
animation: true, final progress = (planned == 0) ? 0.0 : (completed / planned).clamp(0.0, 1.0);
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( return Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: MyText.bodyMedium( child: MyText.bodyMedium(
item.activityMaster?.name ?? item.activityMaster?.name ?? "No Activity",
"No Activity", fontWeight: 600,
fontWeight: 600, maxLines: 2,
maxLines: 2, overflow: TextOverflow.visible,
overflow: TextOverflow.visible, softWrap: true,
softWrap: true, ),
), ),
), MySpacing.width(8),
MySpacing.width(8), if (item.workCategoryMaster?.name != null)
if (item.workCategoryMaster?.name != null) Container(
Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
padding: const EdgeInsets.symmetric( decoration: BoxDecoration(
horizontal: 12, vertical: 4), color: Colors.blue.shade100,
decoration: BoxDecoration( borderRadius: BorderRadius.circular(20),
color: Colors.blue.shade100, ),
borderRadius: child: MyText.bodySmall(
BorderRadius.circular(20), item.workCategoryMaster!.name!,
), fontWeight: 500,
child: MyText.bodySmall( color: Colors.blue.shade800,
item.workCategoryMaster!.name!, ),
fontWeight: 500, ),
color: Colors.blue.shade800, ],
), ),
), MySpacing.height(4),
], Row(
), crossAxisAlignment: CrossAxisAlignment.center,
MySpacing.height(4), children: [
Row( Expanded(
crossAxisAlignment: CrossAxisAlignment.center, flex: 3,
children: [ child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
flex: 3, children: [
child: Column( MySpacing.height(8),
crossAxisAlignment: MyText.bodySmall(
CrossAxisAlignment.start, "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: [ children: [
MySpacing.height(8),
MyText.bodySmall( MyText.bodySmall(
"Completed: $completed / $planned", "Today's Planned: ${item.todaysAssigned ?? 0}",
fontWeight: 600, fontWeight: 600,
color: const Color.fromARGB(
221, 0, 0, 0),
), ),
], ],
), ),
), MySpacing.height(16),
MySpacing.width(16), Stack(
if (progress < 1.0 && children: [
permissionController.hasPermission( Container(
Permissions.assignReportTask)) height: 5,
IconButton( decoration: BoxDecoration(
icon: const Icon( color: Colors.grey[300],
Icons.person_add_alt_1_rounded, borderRadius: BorderRadius.circular(6),
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( FractionallySizedBox(
buildingName: building.name, widthFactor: progress,
floorName: floor.floorName, child: Container(
workAreaName: area.areaName, height: 5,
workLocation: area.areaName, decoration: BoxDecoration(
activityName: color: progress >= 1.0
item.activityMaster?.name ?? ? Colors.green
"Unknown Activity", : (progress >= 0.5 ? Colors.amber : Colors.red),
pendingTask: pendingTask, borderRadius: BorderRadius.circular(6),
workItemId: item.id.toString(), ),
assignmentDate: DateTime.now(),
), ),
); ),
}, ],
), ),
], const SizedBox(height: 4),
), MyText.bodySmall(
"${(progress * 100).toStringAsFixed(1)}%",
MySpacing.height(6), fontWeight: 500,
Row( color: progress >= 1.0
children: [ ? Colors.green[700]
MyText.bodySmall( : (progress >= 0.5 ? Colors.amber[800] : Colors.red[700]),
"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( }).toList(),
height: 5, );
decoration: BoxDecoration( }).toList();
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(),
), ),
), ),
); );