feat: Update Dashboard with Expense Type Report Chart and enhance loading experience with skeleton loaders
This commit is contained in:
parent
68cac95908
commit
4a5fd1c7cc
@ -61,8 +61,10 @@ class DashboardController extends GetxController {
|
|||||||
final RxBool isExpenseTypeReportLoading = false.obs;
|
final RxBool isExpenseTypeReportLoading = false.obs;
|
||||||
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
|
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
|
||||||
Rx<ExpenseTypeReportData?>(null);
|
Rx<ExpenseTypeReportData?>(null);
|
||||||
final Rx<DateTime> expenseReportStartDate = DateTime.now().obs;
|
final Rx<DateTime> expenseReportStartDate =
|
||||||
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||||
|
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
|
|||||||
@ -45,6 +45,10 @@ class Utils {
|
|||||||
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
|
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String formatDate(DateTime date) {
|
||||||
|
return DateFormat('d MMM yyyy').format(date);
|
||||||
|
}
|
||||||
|
|
||||||
static String getDateTimeStringFromDateTime(DateTime dateTime,
|
static String getDateTimeStringFromDateTime(DateTime dateTime,
|
||||||
{bool showSecond = true,
|
{bool showSecond = true,
|
||||||
bool showDate = true,
|
bool showDate = true,
|
||||||
|
|||||||
@ -4,14 +4,16 @@ import 'package:syncfusion_flutter_charts/charts.dart';
|
|||||||
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
||||||
|
|
||||||
import 'package:marco/helpers/utils/utils.dart';
|
import 'package:marco/helpers/utils/utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
|
|
||||||
class ExpenseTypeReportChart extends StatelessWidget {
|
class ExpenseTypeReportChart extends StatelessWidget {
|
||||||
ExpenseTypeReportChart({Key? key}) : super(key: key);
|
ExpenseTypeReportChart({Key? key}) : super(key: key);
|
||||||
|
|
||||||
final DashboardController _controller = Get.find<DashboardController>();
|
final DashboardController _controller = Get.find<DashboardController>();
|
||||||
|
|
||||||
|
// Extended color palette for multiple projects
|
||||||
static const List<Color> _flatColors = [
|
static const List<Color> _flatColors = [
|
||||||
Color(0xFFE57373), // Red 300
|
Color(0xFFE57373), // Red 300
|
||||||
Color(0xFF64B5F6), // Blue 300
|
Color(0xFF64B5F6), // Blue 300
|
||||||
@ -48,35 +50,67 @@ class ExpenseTypeReportChart extends StatelessWidget {
|
|||||||
];
|
];
|
||||||
|
|
||||||
Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length];
|
Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final isMobile = screenWidth < 600;
|
||||||
|
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final isLoading = _controller.isExpenseTypeReportLoading.value;
|
final isLoading = _controller.isExpenseTypeReportLoading.value;
|
||||||
final data = _controller.expenseTypeReportData.value;
|
final data = _controller.expenseTypeReportData.value;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: _containerDecoration,
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
spreadRadius: 1,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
vertical: 16,
|
vertical: isMobile ? 16 : 20,
|
||||||
horizontal: screenWidth < 600 ? 8 : 20,
|
horizontal: isMobile ? 12 : 20,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_Header(),
|
// Chart Header
|
||||||
|
isLoading
|
||||||
|
? SkeletonLoaders.dateSkeletonLoader()
|
||||||
|
: _ChartHeader(controller: _controller),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// 👇 replace Expanded with fixed height
|
|
||||||
|
// Date Range Picker
|
||||||
|
isLoading
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _DateRangePicker(controller: _controller),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Chart Area
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 350, // choose based on your design
|
height: isMobile ? 350 : 400,
|
||||||
child: isLoading
|
child: isLoading
|
||||||
? const Center(child: CircularProgressIndicator())
|
? SkeletonLoaders.chartSkeletonLoader()
|
||||||
: data == null || data.report.isEmpty
|
: (data == null || data.report.isEmpty)
|
||||||
? const _NoDataMessage()
|
? const _NoDataMessage()
|
||||||
: _ExpenseChart(
|
: _ExpenseDonutChart(
|
||||||
data: data,
|
data: data,
|
||||||
getSeriesColor: _getSeriesColor,
|
getSeriesColor: _getSeriesColor,
|
||||||
|
isMobile: isMobile,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -84,146 +118,510 @@ class ExpenseTypeReportChart extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
BoxDecoration get _containerDecoration => BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.grey.withOpacity(0.05),
|
|
||||||
blurRadius: 6,
|
|
||||||
spreadRadius: 1,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
// -----------------------------------------------------------------------------
|
||||||
const _Header({Key? key}) : super(key: key);
|
// Chart Header
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ChartHeader extends StatelessWidget {
|
||||||
|
const _ChartHeader({Key? key, required this.controller}) : super(key: key);
|
||||||
|
|
||||||
|
final DashboardController controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Obx(() {
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
final data = controller.expenseTypeReportData.value;
|
||||||
children: [
|
// Calculate total from totalApprovedAmount only
|
||||||
MyText.bodyMedium('Expense Type Overview', fontWeight: 700),
|
final total = data?.report.fold<double>(
|
||||||
const SizedBox(height: 2),
|
0,
|
||||||
MyText.bodySmall(
|
(sum, e) => sum + e.totalApprovedAmount,
|
||||||
'Project-wise approved, pending, rejected & processed expenses',
|
) ??
|
||||||
color: Colors.grey),
|
0;
|
||||||
],
|
|
||||||
);
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('Project Expense Analytics', fontWeight: 700),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
MyText.bodySmall('Approved expenses by project',
|
||||||
|
color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (total > 0)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: Colors.blueAccent, width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Total Approved',
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
Utils.formatCurrency(total),
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No data
|
// -----------------------------------------------------------------------------
|
||||||
class _NoDataMessage extends StatelessWidget {
|
// Date Range Picker
|
||||||
const _NoDataMessage({Key? key}) : super(key: key);
|
// -----------------------------------------------------------------------------
|
||||||
|
class _DateRangePicker extends StatelessWidget {
|
||||||
|
const _DateRangePicker({Key? key, required this.controller})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
final DashboardController controller;
|
||||||
|
|
||||||
|
Future<void> _selectDate(
|
||||||
|
BuildContext context, bool isStartDate, DateTime currentDate) async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: currentDate,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: const ColorScheme.light(
|
||||||
|
primary: Colors.blueAccent,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSurface: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
if (isStartDate) {
|
||||||
|
controller.expenseReportStartDate.value = picked;
|
||||||
|
} else {
|
||||||
|
controller.expenseReportEndDate.value = picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SizedBox(
|
return Obx(() {
|
||||||
height: 200,
|
final startDate = controller.expenseReportStartDate.value;
|
||||||
child: Center(
|
final endDate = controller.expenseReportEndDate.value;
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48),
|
_DateBox(
|
||||||
const SizedBox(height: 10),
|
label: 'Start Date',
|
||||||
MyText.bodyMedium(
|
date: startDate,
|
||||||
'No expense data available.',
|
onTap: () => _selectDate(context, true, startDate),
|
||||||
textAlign: TextAlign.center,
|
icon: Icons.calendar_today_outlined,
|
||||||
color: Colors.grey.shade500,
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_DateBox(
|
||||||
|
label: 'End Date',
|
||||||
|
date: endDate,
|
||||||
|
onTap: () => _selectDate(context, false, endDate),
|
||||||
|
icon: Icons.event_outlined,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateBox extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final DateTime date;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _DateBox({
|
||||||
|
Key? key,
|
||||||
|
required this.label,
|
||||||
|
required this.date,
|
||||||
|
required this.onTap,
|
||||||
|
required this.icon,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.08),
|
||||||
|
border: Border.all(color: Colors.blueAccent.withOpacity(0.3)),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
],
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
Utils.formatDate(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart
|
// -----------------------------------------------------------------------------
|
||||||
class _ExpenseChart extends StatelessWidget {
|
// No Data Message
|
||||||
const _ExpenseChart({
|
// -----------------------------------------------------------------------------
|
||||||
|
class _NoDataMessage extends StatelessWidget {
|
||||||
|
const _NoDataMessage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.donut_large_outlined,
|
||||||
|
color: Colors.grey.shade400, size: 48),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'No expense data available for this range.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Donut Chart
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ExpenseDonutChart extends StatefulWidget {
|
||||||
|
const _ExpenseDonutChart({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.data,
|
required this.data,
|
||||||
required this.getSeriesColor,
|
required this.getSeriesColor,
|
||||||
|
required this.isMobile,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final ExpenseTypeReportData data;
|
final ExpenseTypeReportData data;
|
||||||
final Color Function(int index) getSeriesColor;
|
final Color Function(int index) getSeriesColor;
|
||||||
|
final bool isMobile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExpenseDonutChart> createState() => _ExpenseDonutChartState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseDonutChartState extends State<_ExpenseDonutChart> {
|
||||||
|
late TooltipBehavior _tooltipBehavior;
|
||||||
|
late SelectionBehavior _selectionBehavior;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tooltipBehavior = TooltipBehavior(
|
||||||
|
enable: true,
|
||||||
|
format: 'point.x: point.y',
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
animationDuration: 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
_selectionBehavior = SelectionBehavior(
|
||||||
|
enable: true,
|
||||||
|
selectedColor: Colors.white,
|
||||||
|
selectedBorderColor: Colors.blueAccent,
|
||||||
|
selectedBorderWidth: 3,
|
||||||
|
unselectedOpacity: 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final List<Map<String, dynamic>> chartSeries = [
|
// Create donut data from project items using totalApprovedAmount
|
||||||
{
|
final List<_DonutData> donutData = widget.data.report
|
||||||
'name': 'Approved',
|
.asMap()
|
||||||
'color': getSeriesColor(0),
|
.entries
|
||||||
'yValue': (ExpenseTypeReportItem e) => e.totalApprovedAmount,
|
.map((entry) => _DonutData(
|
||||||
},
|
entry.value.projectName.isEmpty
|
||||||
{
|
? 'Project ${entry.key + 1}'
|
||||||
'name': 'Pending',
|
: entry.value.projectName,
|
||||||
'color': getSeriesColor(1),
|
entry.value.totalApprovedAmount,
|
||||||
'yValue': (ExpenseTypeReportItem e) => e.totalPendingAmount,
|
widget.getSeriesColor(entry.key),
|
||||||
},
|
Icons.folder_outlined,
|
||||||
{
|
))
|
||||||
'name': 'Rejected',
|
.toList();
|
||||||
'color': getSeriesColor(2),
|
|
||||||
'yValue': (ExpenseTypeReportItem e) => e.totalRejectedAmount,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Processed',
|
|
||||||
'color': getSeriesColor(3),
|
|
||||||
'yValue': (ExpenseTypeReportItem e) => e.totalProcessedAmount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return SfCartesianChart(
|
// Filter out zero values for cleaner visualization
|
||||||
tooltipBehavior: TooltipBehavior(
|
final filteredData = donutData.where((data) => data.value > 0).toList();
|
||||||
enable: true,
|
|
||||||
shared: true,
|
if (filteredData.isEmpty) {
|
||||||
builder: (data, point, series, pointIndex, seriesIndex) {
|
return const Center(
|
||||||
final ExpenseTypeReportItem item = data;
|
child: Text(
|
||||||
final value = chartSeries[seriesIndex]['yValue'](item);
|
'No approved expense data for the selected range.',
|
||||||
return Container(
|
textAlign: TextAlign.center,
|
||||||
padding: const EdgeInsets.all(8),
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Colors.black87,
|
);
|
||||||
borderRadius: BorderRadius.circular(6),
|
}
|
||||||
|
|
||||||
|
// Calculate total for center display
|
||||||
|
final total = filteredData.fold<double>(0, (sum, item) => sum + item.value);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SfCircularChart(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
legend: Legend(
|
||||||
|
isVisible: true,
|
||||||
|
position: LegendPosition.bottom,
|
||||||
|
overflowMode: LegendItemOverflowMode.wrap,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
iconHeight: 10,
|
||||||
|
iconWidth: 10,
|
||||||
|
itemPadding: widget.isMobile ? 6 : 10,
|
||||||
|
padding: widget.isMobile ? 10 : 14,
|
||||||
),
|
),
|
||||||
child: Text(
|
tooltipBehavior: _tooltipBehavior,
|
||||||
'${chartSeries[seriesIndex]['name']}: ${Utils.formatCurrency(value)}',
|
// Center annotation showing total approved amount
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
annotations: <CircularChartAnnotation>[
|
||||||
|
CircularChartAnnotation(
|
||||||
|
widget: Container(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
size: widget.isMobile ? 28 : 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Total Approved',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 11 : 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
Utils.formatCurrency(total),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 16 : 18,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${filteredData.length} ${filteredData.length == 1 ? 'Project' : 'Projects'}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 9 : 10,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
series: <DoughnutSeries<_DonutData, String>>[
|
||||||
|
DoughnutSeries<_DonutData, String>(
|
||||||
|
dataSource: filteredData,
|
||||||
|
xValueMapper: (datum, _) => datum.label,
|
||||||
|
yValueMapper: (datum, _) => datum.value,
|
||||||
|
pointColorMapper: (datum, _) => datum.color,
|
||||||
|
dataLabelMapper: (datum, _) {
|
||||||
|
final percentage =
|
||||||
|
(datum.value / total * 100).toStringAsFixed(1);
|
||||||
|
return widget.isMobile
|
||||||
|
? '$percentage%'
|
||||||
|
: '${datum.label}\n$percentage%';
|
||||||
|
},
|
||||||
|
dataLabelSettings: DataLabelSettings(
|
||||||
|
isVisible: true,
|
||||||
|
labelPosition: ChartDataLabelPosition.outside,
|
||||||
|
connectorLineSettings: ConnectorLineSettings(
|
||||||
|
type: ConnectorType.curve,
|
||||||
|
length: widget.isMobile ? '15%' : '18%',
|
||||||
|
width: 1.5,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 10 : 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
labelIntersectAction: LabelIntersectAction.shift,
|
||||||
|
),
|
||||||
|
// Donut chart specific properties
|
||||||
|
innerRadius: widget.isMobile ? '60%' : '65%',
|
||||||
|
radius: widget.isMobile ? '75%' : '80%',
|
||||||
|
|
||||||
|
// Reduced explode for cleaner donut look
|
||||||
|
explode: true,
|
||||||
|
explodeAll: false,
|
||||||
|
explodeIndex: 0,
|
||||||
|
explodeOffset: '5%',
|
||||||
|
explodeGesture: ActivationMode.singleTap,
|
||||||
|
|
||||||
|
startAngle: 90,
|
||||||
|
endAngle: 450,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
enableTooltip: true,
|
||||||
|
animationDuration: 1000,
|
||||||
|
selectionBehavior: _selectionBehavior,
|
||||||
|
opacity: 0.95,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!widget.isMobile) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ProjectSummary(donutData: filteredData),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Project Summary (Desktop only)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ProjectSummary extends StatelessWidget {
|
||||||
|
const _ProjectSummary({Key? key, required this.donutData}) : super(key: key);
|
||||||
|
|
||||||
|
final List<_DonutData> donutData;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: donutData.map((data) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 120),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: data.color.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(
|
||||||
|
color: data.color.withOpacity(0.4),
|
||||||
|
width: 1,
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
child: Column(
|
||||||
),
|
mainAxisSize: MainAxisSize.min,
|
||||||
legend: Legend(isVisible: true, position: LegendPosition.bottom),
|
children: [
|
||||||
primaryXAxis: CategoryAxis(labelRotation: 45),
|
Icon(data.icon, color: data.color, size: 18),
|
||||||
primaryYAxis: NumericAxis(
|
const SizedBox(height: 4),
|
||||||
// ✅ Format axis labels with Utils
|
Text(
|
||||||
axisLabelFormatter: (AxisLabelRenderDetails details) {
|
data.label,
|
||||||
final num value = details.value;
|
style: TextStyle(
|
||||||
return ChartAxisLabel(
|
fontSize: 11,
|
||||||
Utils.formatCurrency(value), const TextStyle(fontSize: 10));
|
color: Colors.grey.shade700,
|
||||||
},
|
fontWeight: FontWeight.w600,
|
||||||
axisLine: const AxisLine(width: 0),
|
),
|
||||||
majorGridLines: const MajorGridLines(width: 0.5),
|
textAlign: TextAlign.center,
|
||||||
),
|
maxLines: 2,
|
||||||
series: chartSeries.map((seriesInfo) {
|
overflow: TextOverflow.ellipsis,
|
||||||
return ColumnSeries<ExpenseTypeReportItem, String>(
|
),
|
||||||
dataSource: data.report,
|
const SizedBox(height: 2),
|
||||||
xValueMapper: (item, _) => item.projectName,
|
Text(
|
||||||
yValueMapper: (item, _) => seriesInfo['yValue'](item),
|
Utils.formatCurrency(data.value),
|
||||||
name: seriesInfo['name'],
|
style: TextStyle(
|
||||||
color: seriesInfo['color'],
|
fontSize: 12,
|
||||||
dataLabelSettings: const DataLabelSettings(isVisible: true),
|
color: data.color,
|
||||||
// ✅ Format data labels as well
|
fontWeight: FontWeight.w700,
|
||||||
dataLabelMapper: (item, _) =>
|
),
|
||||||
Utils.formatCurrency(seriesInfo['yValue'](item)),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _DonutData {
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
_DonutData(this.label, this.value, this.color, this.icon);
|
||||||
|
}
|
||||||
|
|||||||
@ -33,6 +33,65 @@ class SkeletonLoaders {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chart Skeleton Loader (Donut Chart)
|
||||||
|
static Widget chartSkeletonLoader() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
paddingAll: 16,
|
||||||
|
borderRadiusAll: 12,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Chart Header Placeholder
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Donut Skeleton Placeholder
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.grey.shade300.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Legend placeholders
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(5, (index) {
|
||||||
|
return Container(
|
||||||
|
width: 100,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Date Skeleton Loader
|
// Date Skeleton Loader
|
||||||
static Widget dateSkeletonLoader() {
|
static Widget dateSkeletonLoader() {
|
||||||
return Container(
|
return Container(
|
||||||
@ -180,74 +239,6 @@ class SkeletonLoaders {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart Skeleton Loader
|
|
||||||
static Widget chartSkeletonLoader() {
|
|
||||||
return MyCard.bordered(
|
|
||||||
margin: MySpacing.only(bottom: 12),
|
|
||||||
paddingAll: 16,
|
|
||||||
borderRadiusAll: 16,
|
|
||||||
shadow: MyShadow(
|
|
||||||
elevation: 1.5,
|
|
||||||
position: MyShadowPosition.bottom,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Chart Title Placeholder
|
|
||||||
Container(
|
|
||||||
height: 14,
|
|
||||||
width: 120,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.height(20),
|
|
||||||
|
|
||||||
// Chart Bars (variable height for realism)
|
|
||||||
SizedBox(
|
|
||||||
height: 180,
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: List.generate(6, (index) {
|
|
||||||
return Expanded(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: Container(
|
|
||||||
height:
|
|
||||||
(60 + (index * 20)).toDouble(), // fake chart shape
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
MySpacing.height(16),
|
|
||||||
|
|
||||||
// X-Axis Labels
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: List.generate(6, (index) {
|
|
||||||
return Container(
|
|
||||||
height: 10,
|
|
||||||
width: 30,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Document List Skeleton Loader
|
// Document List Skeleton Loader
|
||||||
static Widget documentSkeletonLoader() {
|
static Widget documentSkeletonLoader() {
|
||||||
return Column(
|
return Column(
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import 'package:marco/helpers/widgets/my_text.dart';
|
|||||||
import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart';
|
import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart';
|
||||||
import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||||
import 'package:marco/view/layouts/layout.dart';
|
import 'package:marco/view/layouts/layout.dart';
|
||||||
// import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||||
|
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
@ -61,8 +61,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
ExpenseByStatusWidget(controller: dashboardController),
|
ExpenseByStatusWidget(controller: dashboardController),
|
||||||
MySpacing.height(24),
|
MySpacing.height(24),
|
||||||
|
|
||||||
// // Expense Type Report Chart
|
// Expense Type Report Chart
|
||||||
// ExpenseTypeReportChart(),
|
ExpenseTypeReportChart(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user