feat: Enhance dashboard functionality with attendance overview and chart visualization

This commit is contained in:
Vaibhav Surve 2025-06-20 19:01:58 +05:30
parent ca8bc26ab5
commit 6cdf35374d
7 changed files with 454 additions and 37 deletions

View File

@ -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<ProjectModel> projects = <ProjectModel>[].obs;
RxString? selectedProjectId;
var isProjectListExpanded = false.obs;
RxBool isProjectSelectionExpanded = true.obs;
// Observables
final RxList<Map<String, dynamic>> roleWiseData = <Map<String, dynamic>>[].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<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
// Inject the ProjectController
final ProjectController projectController = Get.find<ProjectController>();
@override
void onInit() {
super.onInit();
fetchProjects();
}
/// Fetches projects and initializes selected project.
Future<void> 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<T> with non-nullable type
ever<String>(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<void> refreshDashboard() async {
await fetchRoleWiseAttendance();
}
Future<void> 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<dynamic>? response =
await ApiService.getDashboardAttendanceOverview(projectId, rangeDays);
if (response != null) {
roleWiseData.value =
response.map((e) => Map<String, dynamic>.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;
}
}
}

View File

@ -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";

View File

@ -122,6 +122,21 @@ class ApiService {
}
}
// === Dashboard Endpoints ===
static Future<List<dynamic>?> 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<List<dynamic>?> getProjects() async =>

View File

@ -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(

View File

@ -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<String, dynamic> json) {
return AttendanceOverview(
role: json['role'] ?? '',
date: json['date'] ?? '',
present: json['present'] ?? 0,
);
}
}

View File

@ -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<DashboardController>();
AttendanceDashboardChart({super.key});
List<Map<String, dynamic>> 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<DateTime> get filteredDateTimes {
final uniqueDates = filteredData
.map((e) => DateTime.parse(e['date'] as String))
.toSet()
.toList()
..sort();
return uniqueDates;
}
List<String> get filteredDates =>
filteredDateTimes.map((d) => DateFormat('d MMMM').format(d)).toList();
List<String> get filteredRoles =>
filteredData.map((e) => e['role'] as String).toSet().toList();
final Map<String, Color> _roleColorMap = {};
final List<Color> 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<String>(
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<Map<String, dynamic>, 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),
);
}
}

View File

@ -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<DashboardScreen> with UIMixin {
final DashboardController dashboardController =
Get.put(DashboardController());
bool hasMpin = true;
@override
@ -53,6 +58,9 @@ class _DashboardScreenState extends State<DashboardScreen> 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<DashboardScreen> 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<DashboardScreen> 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(