From 6cdf35374d467a64d84a7446eb97cb209999dc29 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 20 Jun 2025 19:01:58 +0530 Subject: [PATCH] feat: Enhance dashboard functionality with attendance overview and chart visualization --- .../dashboard/dashboard_controller.dart | 110 +++++-- lib/helpers/services/api_endpoints.dart | 3 + lib/helpers/services/api_service.dart | 15 + lib/helpers/widgets/my_custom_skeleton.dart | 31 ++ .../dashboard/attendance_overview_model.dart | 19 ++ lib/view/dashboard/dashboard_chart.dart | 295 ++++++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 18 +- 7 files changed, 454 insertions(+), 37 deletions(-) create mode 100644 lib/model/dashboard/attendance_overview_model.dart create mode 100644 lib/view/dashboard/dashboard_chart.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 2cd18a1..2ce16bc 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -1,54 +1,100 @@ import 'package:get/get.dart'; import 'package:logger/logger.dart'; import 'package:marco/helpers/services/api_service.dart'; -import 'package:marco/model/project_model.dart'; +import 'package:marco/controller/project_controller.dart'; final Logger log = Logger(); class DashboardController extends GetxController { - RxList projects = [].obs; - RxString? selectedProjectId; - var isProjectListExpanded = false.obs; - RxBool isProjectSelectionExpanded = true.obs; + // Observables + final RxList> roleWiseData = >[].obs; + final RxBool isLoading = false.obs; + final RxString selectedRange = '7D'.obs; + final RxBool isChartView = true.obs; - void toggleProjectListExpanded() { - isProjectListExpanded.value = !isProjectListExpanded.value; - } - - var isProjectDropdownExpanded = false.obs; - - RxBool isLoading = true.obs; - RxBool isLoadingProjects = true.obs; - RxMap uploadingStates = {}.obs; + // Inject the ProjectController + final ProjectController projectController = Get.find(); @override void onInit() { super.onInit(); - fetchProjects(); - } - /// Fetches projects and initializes selected project. - Future fetchProjects() async { - isLoadingProjects.value = true; - isLoading.value = true; + final selectedProjectIdRx = projectController.selectedProjectId; - final response = await ApiService.getProjects(); + if (selectedProjectIdRx != null) { + // Fix: explicitly cast and use ever with non-nullable type + ever(selectedProjectIdRx, (id) { + if (id.isNotEmpty) { + fetchRoleWiseAttendance(); + } + }); - if (response != null && response.isNotEmpty) { - projects.assignAll( - response.map((json) => ProjectModel.fromJson(json)).toList()); - selectedProjectId = RxString(projects.first.id.toString()); - log.i("Projects fetched: ${projects.length}"); + // Initial load if already has value + if (selectedProjectIdRx.value.isNotEmpty) { + fetchRoleWiseAttendance(); + } } else { - log.w("No projects found or API call failed."); + log.w('selectedProjectId observable is null in ProjectController.'); } - isLoadingProjects.value = false; - isLoading.value = false; - update(['dashboard_controller']); + ever(selectedRange, (_) { + fetchRoleWiseAttendance(); + }); } - void updateSelectedProject(String projectId) { - selectedProjectId?.value = projectId; + int get rangeDays => _getDaysFromRange(selectedRange.value); + + int _getDaysFromRange(String range) { + switch (range) { + case '15D': + return 15; + case '30D': + return 30; + case '7D': + default: + return 7; + } + } + + void updateRange(String range) { + selectedRange.value = range; + } + + void toggleChartView(bool isChart) { + isChartView.value = isChart; + } + + Future refreshDashboard() async { + await fetchRoleWiseAttendance(); + } + + Future fetchRoleWiseAttendance() async { + final String? projectId = projectController.selectedProjectId?.value; + + if (projectId == null || projectId.isEmpty) { + log.w('Project ID is null or empty, skipping API call.'); + return; + } + + try { + isLoading.value = true; + + final List? response = + await ApiService.getDashboardAttendanceOverview(projectId, rangeDays); + + if (response != null) { + roleWiseData.value = + response.map((e) => Map.from(e)).toList(); + log.i('Attendance overview fetched successfully.'); + } else { + log.e('Failed to fetch attendance overview: response is null.'); + roleWiseData.clear(); + } + } catch (e, st) { + log.e('Error fetching attendance overview', error: e, stackTrace: st); + roleWiseData.clear(); + } finally { + isLoading.value = false; + } } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 87b11cf..12809fd 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -2,6 +2,9 @@ class ApiEndpoints { static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; + // Dashboard Screen API Endpoints + static const String getDashboardAttendanceOverview = "/dashboard/attendance-overview"; + // Attendance Screen API Endpoints static const String getProjects = "/project/list"; static const String getGlobalProjects = "/project/list/basic"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index bd7f81c..7e43290 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -122,6 +122,21 @@ class ApiService { } } +// === Dashboard Endpoints === + + static Future?> getDashboardAttendanceOverview( + String projectId, int days) async { + if (projectId.isEmpty) throw ArgumentError('projectId must not be empty'); + if (days <= 0) throw ArgumentError('days must be greater than 0'); + + final endpoint = + "${ApiEndpoints.getDashboardAttendanceOverview}/$projectId?days=$days"; + + return _getRequest(endpoint).then((res) => res != null + ? _parseResponse(res, label: 'Dashboard Attendance Overview') + : null); + } + // === Attendance APIs === static Future?> getProjects() async => diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 241ca2a..1ba3a3e 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -4,6 +4,37 @@ import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/utils/my_shadow.dart'; class SkeletonLoaders { + +static Widget buildLoadingSkeleton() { + return SizedBox( + height: 360, + child: Column( + children: List.generate(5, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: List.generate(6, (i) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + width: 48, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ); + }), + ), + ), + ); + }), + ), + ); +} + + // Employee List - Card Style static Widget employeeListSkeletonLoader() { return Column( diff --git a/lib/model/dashboard/attendance_overview_model.dart b/lib/model/dashboard/attendance_overview_model.dart new file mode 100644 index 0000000..3124e3f --- /dev/null +++ b/lib/model/dashboard/attendance_overview_model.dart @@ -0,0 +1,19 @@ +class AttendanceOverview { + final String role; + final String date; + final int present; + + AttendanceOverview({ + required this.role, + required this.date, + required this.present, + }); + + factory AttendanceOverview.fromJson(Map json) { + return AttendanceOverview( + role: json['role'] ?? '', + date: json['date'] ?? '', + present: json['present'] ?? 0, + ); + } +} diff --git a/lib/view/dashboard/dashboard_chart.dart b/lib/view/dashboard/dashboard_chart.dart new file mode 100644 index 0000000..d4edec1 --- /dev/null +++ b/lib/view/dashboard/dashboard_chart.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:syncfusion_flutter_charts/charts.dart'; +import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; + +class AttendanceDashboardChart extends StatelessWidget { + final DashboardController controller = Get.find(); + + AttendanceDashboardChart({super.key}); + + List> get filteredData { + final now = DateTime.now(); + final daysBack = controller.rangeDays; + return controller.roleWiseData.where((entry) { + final date = DateTime.parse(entry['date']); + return date.isAfter(now.subtract(Duration(days: daysBack))) && + !date.isAfter(now); + }).toList(); + } + + List get filteredDateTimes { + final uniqueDates = filteredData + .map((e) => DateTime.parse(e['date'] as String)) + .toSet() + .toList() + ..sort(); + return uniqueDates; + } + + List get filteredDates => + filteredDateTimes.map((d) => DateFormat('d MMMM').format(d)).toList(); + + List get filteredRoles => + filteredData.map((e) => e['role'] as String).toSet().toList(); + + final Map _roleColorMap = {}; + final List flatColors = [ + const Color(0xFFE57373), + const Color(0xFF64B5F6), + const Color(0xFF81C784), + const Color(0xFFFFB74D), + const Color(0xFFBA68C8), + const Color(0xFFFF8A65), + const Color(0xFF4DB6AC), + const Color(0xFFA1887F), + const Color(0xFFDCE775), + const Color(0xFF9575CD), + ]; + + Color _getRoleColor(String role) { + if (_roleColorMap.containsKey(role)) return _roleColorMap[role]!; + + final index = _roleColorMap.length % flatColors.length; + final color = flatColors[index]; + _roleColorMap[role] = color; + return color; + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final isChartView = controller.isChartView.value; + final selectedRange = controller.selectedRange.value; + final isLoading = controller.isLoading.value; + + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Color(0xfff0f4f8), Color(0xffe2ebf0)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Card( + color: Colors.white, + elevation: 6, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + shadowColor: Colors.black12, + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(selectedRange, isChartView), + const SizedBox(height: 12), + AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: isLoading + ? SkeletonLoaders.buildLoadingSkeleton() + : isChartView + ? _buildChart() + : _buildTable(), + ), + ], + ), + ), + ), + ); + }); + } + + Widget _buildHeader(String selectedRange, bool isChartView) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Attendance Overview', fontWeight: 600), + MyText.bodySmall( + 'Role-wise present count', + color: Colors.grey, + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + padding: EdgeInsets.zero, + tooltip: 'Select Range', + onSelected: (value) => controller.selectedRange.value = value, + itemBuilder: (context) => const [ + PopupMenuItem(value: '7D', child: Text('Last 7 Days')), + PopupMenuItem(value: '15D', child: Text('Last 15 Days')), + PopupMenuItem(value: '30D', child: Text('Last 30 Days')), + ], + child: Row( + children: [ + const Icon(Icons.calendar_today_outlined, size: 18), + const SizedBox(width: 4), + MyText.labelSmall(selectedRange), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + Icons.bar_chart_rounded, + size: 20, + color: isChartView ? Colors.blueAccent : Colors.grey, + ), + visualDensity: VisualDensity(horizontal: -4, vertical: -4), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + onPressed: () => controller.isChartView.value = true, + tooltip: 'Chart View', + ), + IconButton( + icon: Icon( + Icons.table_chart, + size: 20, + color: !isChartView ? Colors.blueAccent : Colors.grey, + ), + visualDensity: VisualDensity(horizontal: -4, vertical: -4), + constraints: const BoxConstraints(), + padding: EdgeInsets.zero, + onPressed: () => controller.isChartView.value = false, + tooltip: 'Table View', + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildChart() { + final formattedDateMap = { + for (var e in filteredData) + '${e['role']}_${DateFormat('d MMMM').format(DateTime.parse(e['date']))}': + e['present'] + }; + + return SizedBox( + height: 360, + child: SfCartesianChart( + tooltipBehavior: TooltipBehavior( + enable: true, + shared: true, + activationMode: ActivationMode.singleTap, + tooltipPosition: TooltipPosition.pointer, + ), + legend: const Legend( + isVisible: true, + position: LegendPosition.bottom, + overflowMode: LegendItemOverflowMode.wrap, + ), + primaryXAxis: CategoryAxis( + labelRotation: 45, + majorGridLines: const MajorGridLines(width: 0), + ), + primaryYAxis: NumericAxis( + minimum: 0, + interval: 1, + majorGridLines: const MajorGridLines(width: 0), + ), + series: filteredRoles.map((role) { + final data = filteredDates.map((formattedDate) { + final key = '${role}_$formattedDate'; + return { + 'date': formattedDate, + 'present': formattedDateMap[key] ?? 0 + }; + }).toList(); + + return StackedColumnSeries, String>( + dataSource: data, + xValueMapper: (d, _) => d['date'], + yValueMapper: (d, _) => d['present'], + name: role, + legendIconType: LegendIconType.circle, + dataLabelSettings: const DataLabelSettings(isVisible: true), + color: _getRoleColor(role), + ); + }).toList(), + ), + ); + } + + Widget _buildTable() { + final formattedDateMap = { + for (var e in filteredData) + '${e['role']}_${DateFormat('d MMMM').format(DateTime.parse(e['date']))}': + e['present'] + }; + + return Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + columnSpacing: 28, + headingRowHeight: 42, + headingRowColor: + WidgetStateProperty.all(Colors.blueAccent.withOpacity(0.1)), + headingTextStyle: const TextStyle( + fontWeight: FontWeight.bold, color: Colors.black87), + columns: [ + DataColumn(label: MyText.labelSmall('Role', fontWeight: 600)), + ...filteredDates.map((date) => DataColumn( + label: MyText.labelSmall(date, fontWeight: 600), + )), + ], + rows: filteredRoles.map((role) { + return DataRow( + cells: [ + DataCell(Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _rolePill(role), + )), + ...filteredDates.map((date) { + final key = '${role}_$date'; + return DataCell(Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: MyText.labelSmall('${formattedDateMap[key] ?? 0}'), + )); + }), + ], + ); + }).toList(), + ), + ), + ); + } + + Widget _rolePill(String role) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: _getRoleColor(role).withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: MyText.labelSmall(role, fontWeight: 500), + ); + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 82ebe76..bb3e0b6 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -1,19 +1,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; +import 'package:marco/controller/dashboard/dashboard_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/my_shadow.dart'; +import 'package:marco/helpers/widgets/my_button.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/view/dashboard/dashboard_chart.dart'; import 'package:marco/view/layouts/layout.dart'; -import 'package:marco/helpers/services/storage/local_storage.dart'; -import 'package:marco/helpers/widgets/my_button.dart'; -import 'package:marco/controller/project_controller.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); + static const String dashboardRoute = "/dashboard"; static const String employeesRoute = "/dashboard/employees"; static const String projectsRoute = "/dashboard"; @@ -28,6 +31,8 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State with UIMixin { + final DashboardController dashboardController = + Get.put(DashboardController()); bool hasMpin = true; @override @@ -53,6 +58,9 @@ class _DashboardScreenState extends State with UIMixin { children: [ MySpacing.height(12), _buildDashboardStats(), + MySpacing.height(24), + AttendanceDashboardChart(), + MySpacing.height(300), if (!hasMpin) ...[ MyCard( @@ -63,7 +71,7 @@ class _DashboardScreenState extends State with UIMixin { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(Icons.warning_amber_rounded, + const Icon(Icons.warning_amber_rounded, color: Colors.redAccent, size: 28), MySpacing.width(12), Expanded( @@ -93,7 +101,7 @@ class _DashboardScreenState extends State with UIMixin { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.lock_outline, + const Icon(Icons.lock_outline, size: 18, color: Colors.white), MySpacing.width(8), MyText.bodyMedium(