feat: Enhance dashboard functionality with attendance overview and chart visualization
This commit is contained in:
parent
ca8bc26ab5
commit
6cdf35374d
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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";
|
||||
|
@ -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 =>
|
||||
|
@ -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(
|
||||
|
19
lib/model/dashboard/attendance_overview_model.dart
Normal file
19
lib/model/dashboard/attendance_overview_model.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
295
lib/view/dashboard/dashboard_chart.dart
Normal file
295
lib/view/dashboard/dashboard_chart.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user