363 lines
12 KiB
Dart
363 lines
12 KiB
Dart
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/model/dashboard/project_progress_model.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 ProjectProgressChart extends StatelessWidget {
|
|
final List<ChartTaskData> data;
|
|
final DashboardController controller = Get.find<DashboardController>();
|
|
|
|
ProjectProgressChart({super.key, required this.data});
|
|
|
|
// ================= Flat Colors =================
|
|
static const List<Color> _flatColors = [
|
|
Color(0xFFE57373),
|
|
Color(0xFF64B5F6),
|
|
Color(0xFF81C784),
|
|
Color(0xFFFFB74D),
|
|
Color(0xFFBA68C8),
|
|
Color(0xFFFF8A65),
|
|
Color(0xFF4DB6AC),
|
|
Color(0xFFA1887F),
|
|
Color(0xFFDCE775),
|
|
Color(0xFF9575CD),
|
|
Color(0xFF7986CB),
|
|
Color(0xFFAED581),
|
|
Color(0xFFFF7043),
|
|
Color(0xFF4FC3F7),
|
|
Color(0xFFFFD54F),
|
|
Color(0xFF90A4AE),
|
|
Color(0xFFE573BB),
|
|
Color(0xFF81D4FA),
|
|
Color(0xFFBCAAA4),
|
|
Color(0xFFA5D6A7),
|
|
Color(0xFFCE93D8),
|
|
Color(0xFFFF8A65),
|
|
Color(0xFF80CBC4),
|
|
Color(0xFFFFF176),
|
|
Color(0xFF90CAF9),
|
|
Color(0xFFE0E0E0),
|
|
Color(0xFFF48FB1),
|
|
Color(0xFFA1887F),
|
|
Color(0xFFB0BEC5),
|
|
Color(0xFF81C784),
|
|
Color(0xFFFFB74D),
|
|
Color(0xFF64B5F6),
|
|
];
|
|
static final NumberFormat _commaFormatter = NumberFormat.decimalPattern();
|
|
|
|
Color _getTaskColor(String taskName) {
|
|
final index = taskName.hashCode % _flatColors.length;
|
|
return _flatColors[index];
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
|
|
return Obx(() {
|
|
final isChartView = controller.projectIsChartView.value;
|
|
final selectedRange = controller.projectSelectedRange.value;
|
|
final isLoading = controller.isProjectLoading.value;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(14),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.grey.withOpacity(0.04),
|
|
blurRadius: 6,
|
|
spreadRadius: 1,
|
|
offset: Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: EdgeInsets.symmetric(
|
|
vertical: 16,
|
|
horizontal: screenWidth < 600 ? 8 : 24,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(selectedRange, isChartView, screenWidth),
|
|
const SizedBox(height: 14),
|
|
Expanded(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) => AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 300),
|
|
child: isLoading
|
|
? SkeletonLoaders.buildLoadingSkeleton()
|
|
: data.isEmpty
|
|
? _buildNoDataMessage()
|
|
: isChartView
|
|
? _buildChart(constraints.maxHeight)
|
|
: _buildTable(constraints.maxHeight, screenWidth),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
Widget _buildHeader(
|
|
String selectedRange, bool isChartView, double screenWidth) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium('Project Progress', fontWeight: 700),
|
|
MyText.bodySmall('Planned vs Completed',
|
|
color: Colors.grey.shade700),
|
|
],
|
|
),
|
|
),
|
|
ToggleButtons(
|
|
borderRadius: BorderRadius.circular(6),
|
|
borderColor: Colors.grey,
|
|
fillColor: Colors.blueAccent.withOpacity(0.15),
|
|
selectedBorderColor: Colors.blueAccent,
|
|
selectedColor: Colors.blueAccent,
|
|
color: Colors.grey,
|
|
constraints: BoxConstraints(
|
|
minHeight: 30,
|
|
minWidth: (screenWidth < 400 ? 28 : 36),
|
|
),
|
|
isSelected: [isChartView, !isChartView],
|
|
onPressed: (index) {
|
|
controller.projectIsChartView.value = index == 0;
|
|
},
|
|
children: const [
|
|
Icon(Icons.bar_chart_rounded, size: 15),
|
|
Icon(Icons.table_chart, size: 15),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
_buildRangeButton("7D", selectedRange),
|
|
_buildRangeButton("15D", selectedRange),
|
|
_buildRangeButton("30D", selectedRange),
|
|
_buildRangeButton("3M", selectedRange),
|
|
_buildRangeButton("6M", selectedRange),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRangeButton(String label, String selectedRange) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 4.0),
|
|
child: ChoiceChip(
|
|
label: Text(label, style: const TextStyle(fontSize: 12)),
|
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
visualDensity: VisualDensity.compact,
|
|
selected: selectedRange == label,
|
|
onSelected: (_) => controller.updateProjectRange(label),
|
|
selectedColor: Colors.blueAccent.withOpacity(0.15),
|
|
backgroundColor: Colors.grey.shade200,
|
|
labelStyle: TextStyle(
|
|
color: selectedRange == label ? Colors.blueAccent : Colors.black87,
|
|
fontWeight:
|
|
selectedRange == label ? FontWeight.w600 : FontWeight.normal,
|
|
),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(6),
|
|
side: BorderSide(
|
|
color: selectedRange == label
|
|
? Colors.blueAccent
|
|
: Colors.grey.shade300,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildChart(double height) {
|
|
final nonZeroData =
|
|
data.where((d) => d.planned != 0 || d.completed != 0).toList();
|
|
|
|
if (nonZeroData.isEmpty) {
|
|
return _buildNoDataContainer(height);
|
|
}
|
|
|
|
return Container(
|
|
height: height > 280 ? 280 : height,
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blueGrey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: SfCartesianChart(
|
|
tooltipBehavior: TooltipBehavior(enable: true),
|
|
legend: Legend(isVisible: true, position: LegendPosition.bottom),
|
|
// ✅ Use CategoryAxis so only nonZeroData dates show up
|
|
primaryXAxis: CategoryAxis(
|
|
majorGridLines: const MajorGridLines(width: 0),
|
|
axisLine: const AxisLine(width: 0),
|
|
labelRotation: 0,
|
|
),
|
|
primaryYAxis: NumericAxis(
|
|
labelFormat: '{value}',
|
|
axisLine: const AxisLine(width: 0),
|
|
majorTickLines: const MajorTickLines(size: 0),
|
|
),
|
|
series: <CartesianSeries>[
|
|
ColumnSeries<ChartTaskData, String>(
|
|
name: 'Planned',
|
|
dataSource: nonZeroData,
|
|
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date),
|
|
yValueMapper: (d, _) => d.planned,
|
|
color: _getTaskColor('Planned'),
|
|
dataLabelSettings: DataLabelSettings(
|
|
isVisible: true,
|
|
builder: (data, point, series, pointIndex, seriesIndex) {
|
|
final value = seriesIndex == 0
|
|
? (data as ChartTaskData).planned
|
|
: (data as ChartTaskData).completed;
|
|
return Text(
|
|
_commaFormatter.format(value),
|
|
style: const TextStyle(fontSize: 11),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
ColumnSeries<ChartTaskData, String>(
|
|
name: 'Completed',
|
|
dataSource: nonZeroData,
|
|
xValueMapper: (d, _) => DateFormat('MMM d').format(d.date),
|
|
yValueMapper: (d, _) => d.completed,
|
|
color: _getTaskColor('Completed'),
|
|
dataLabelSettings: DataLabelSettings(
|
|
isVisible: true,
|
|
builder: (data, point, series, pointIndex, seriesIndex) {
|
|
final value = seriesIndex == 0
|
|
? (data as ChartTaskData).planned
|
|
: (data as ChartTaskData).completed;
|
|
return Text(
|
|
_commaFormatter.format(value),
|
|
style: const TextStyle(fontSize: 11),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTable(double maxHeight, double screenWidth) {
|
|
final containerHeight = maxHeight > 300 ? 300.0 : maxHeight;
|
|
final nonZeroData =
|
|
data.where((d) => d.planned != 0 || d.completed != 0).toList();
|
|
|
|
if (nonZeroData.isEmpty) {
|
|
return _buildNoDataContainer(containerHeight);
|
|
}
|
|
|
|
return Container(
|
|
height: containerHeight,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(12),
|
|
color: Colors.grey.shade50,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(minWidth: constraints.maxWidth),
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.vertical,
|
|
child: DataTable(
|
|
columnSpacing: screenWidth < 600 ? 16 : 36,
|
|
headingRowHeight: 44,
|
|
headingRowColor: MaterialStateProperty.all(
|
|
Colors.blueAccent.withOpacity(0.08)),
|
|
headingTextStyle: const TextStyle(
|
|
fontWeight: FontWeight.bold, color: Colors.black87),
|
|
columns: const [
|
|
DataColumn(label: Text('Date')),
|
|
DataColumn(label: Text('Planned')),
|
|
DataColumn(label: Text('Completed')),
|
|
],
|
|
rows: nonZeroData.map((task) {
|
|
return DataRow(
|
|
cells: [
|
|
DataCell(Text(DateFormat('d MMM').format(task.date))),
|
|
DataCell(Text(
|
|
'${task.planned}',
|
|
style: TextStyle(color: _getTaskColor('Planned')),
|
|
)),
|
|
DataCell(Text(
|
|
'${task.completed}',
|
|
style: TextStyle(color: _getTaskColor('Completed')),
|
|
)),
|
|
],
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNoDataContainer(double height) {
|
|
return Container(
|
|
height: height > 280 ? 280 : height,
|
|
decoration: BoxDecoration(
|
|
color: Colors.blueGrey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: const Center(
|
|
child: Text(
|
|
'No project progress data for the selected range.',
|
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildNoDataMessage() {
|
|
return SizedBox(
|
|
height: 180,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 54),
|
|
const SizedBox(height: 10),
|
|
MyText.bodyMedium(
|
|
'No project progress data available for the selected range.',
|
|
textAlign: TextAlign.center,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|