427 lines
13 KiB
Dart
427 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
|
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
|
|
import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
|
|
|
class CollectionsHealthWidget extends StatelessWidget {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final DashboardController controller = Get.find<DashboardController>();
|
|
|
|
return Obx(() {
|
|
final data = controller.collectionOverviewData.value;
|
|
final isLoading = controller.isCollectionOverviewLoading.value;
|
|
|
|
// Loading state
|
|
if (isLoading) {
|
|
return Container(
|
|
decoration: _boxDecoration(), // Maintain the outer box decoration
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: SkeletonLoaders.collectionHealthSkeleton(),
|
|
);
|
|
}
|
|
|
|
// No data
|
|
if (data == null) {
|
|
return Container(
|
|
decoration: _boxDecoration(),
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Center(
|
|
child: MyText.bodyMedium('No collection overview data available.'),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Data available
|
|
final double totalDue = data.totalDueAmount;
|
|
final double totalCollected = data.totalCollectedAmount;
|
|
final double pendingPercentage = data.pendingPercentage / 100.0;
|
|
final double dsoDays = controller.calculatedDSO;
|
|
|
|
return Container(
|
|
decoration: _boxDecoration(),
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
_buildHeader(),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Expanded(
|
|
flex: 5,
|
|
child: _buildLeftChartSection(
|
|
totalDue: totalDue,
|
|
pendingPercentage: pendingPercentage,
|
|
totalCollected: totalCollected,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
flex: 4,
|
|
child: _buildRightMetricsSection(
|
|
data: data,
|
|
dsoDays: dsoDays,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
_buildAgingAnalysis(data: data),
|
|
],
|
|
),
|
|
);
|
|
});
|
|
}
|
|
|
|
// ==============================
|
|
// HEADER
|
|
// ==============================
|
|
Widget _buildHeader() {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium('Collections Health Overview', fontWeight: 700),
|
|
const SizedBox(height: 2),
|
|
MyText.bodySmall('View your collection health data.',
|
|
color: Colors.grey),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ==============================
|
|
// LEFT SECTION (GAUGE + SUMMARY)
|
|
// ==============================
|
|
Widget _buildLeftChartSection({
|
|
required double totalDue,
|
|
required double pendingPercentage,
|
|
required double totalCollected,
|
|
}) {
|
|
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
|
|
String collectedPercentStr =
|
|
((1 - pendingPercentage) * 100).toStringAsFixed(0);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
Row(
|
|
children: [
|
|
_GaugeChartPlaceholder(
|
|
backgroundColor: Colors.white,
|
|
pendingPercentage: pendingPercentage,
|
|
),
|
|
const SizedBox(width: 12),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
MyText.bodyLarge(
|
|
'₹${totalDue.toStringAsFixed(0)} DUE',
|
|
fontWeight: 700,
|
|
),
|
|
const SizedBox(height: 4),
|
|
MyText.bodySmall(
|
|
'• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)',
|
|
color: Colors.black54,
|
|
),
|
|
MyText.bodySmall(
|
|
'₹${totalCollected.toStringAsFixed(0)} Collected',
|
|
color: Colors.black54,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ==============================
|
|
// RIGHT METRICS SECTION
|
|
// ==============================
|
|
Widget _buildRightMetricsSection({
|
|
required CollectionOverviewData data,
|
|
required double dsoDays,
|
|
}) {
|
|
final String topClientName = data.topClient?.name ?? 'N/A';
|
|
final double topClientBalance = data.topClientBalance;
|
|
|
|
return Column(
|
|
children: <Widget>[
|
|
_buildMetricCard(
|
|
title: 'Top Client Balance',
|
|
value: topClientName,
|
|
subValue: '₹${topClientBalance.toStringAsFixed(0)}',
|
|
valueColor: Colors.red,
|
|
isDetailed: true,
|
|
),
|
|
const SizedBox(height: 10),
|
|
_buildMetricCard(
|
|
title: 'Total Collected (YTD)',
|
|
value: '₹${data.totalCollectedAmount.toStringAsFixed(0)}',
|
|
subValue: 'Collected',
|
|
valueColor: Colors.green,
|
|
isDetailed: false,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMetricCard({
|
|
required String title,
|
|
required String value,
|
|
required String subValue,
|
|
required Color valueColor,
|
|
required bool isDetailed,
|
|
}) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFF5F5F5),
|
|
borderRadius: BorderRadius.circular(5),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: <Widget>[
|
|
MyText.bodySmall(title, color: Colors.black54),
|
|
const SizedBox(height: 2),
|
|
if (isDetailed) ...[
|
|
MyText.bodySmall(value, fontWeight: 600),
|
|
MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700),
|
|
] else
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: <Widget>[
|
|
MyText.bodySmall(value, fontWeight: 600),
|
|
MyText.bodySmall(subValue, color: valueColor, fontWeight: 600),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ==============================
|
|
// AGING ANALYSIS
|
|
// ==============================
|
|
Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
|
|
final buckets = [
|
|
AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green,
|
|
data.bucket0To30Invoices),
|
|
AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange,
|
|
data.bucket30To60Invoices),
|
|
AgingBucketData('60-90 Days', data.bucket60To90Amount,
|
|
Colors.red.shade300, data.bucket60To90Invoices),
|
|
AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red,
|
|
data.bucket90PlusInvoices),
|
|
];
|
|
|
|
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
MyText.bodyMedium('Outstanding Collections Aging Analysis',
|
|
fontWeight: 700),
|
|
MyText.bodySmall(
|
|
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
|
|
color: Colors.black54),
|
|
const SizedBox(height: 10),
|
|
_AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding),
|
|
const SizedBox(height: 15),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 8,
|
|
children: buckets
|
|
.map((bucket) => _buildAgingLegendItem(bucket.title,
|
|
bucket.amount, bucket.color, bucket.invoiceCount))
|
|
.toList(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildAgingLegendItem(
|
|
String title, double amount, Color color, int count) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 10,
|
|
height: 10,
|
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
|
|
const SizedBox(width: 6),
|
|
MyText.bodySmall(
|
|
'$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'),
|
|
],
|
|
);
|
|
}
|
|
|
|
BoxDecoration _boxDecoration() {
|
|
return BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(5),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 5),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
|
|
// =====================================================================
|
|
|
|
// Gauge Chart
|
|
class _GaugeChartPlaceholder extends StatelessWidget {
|
|
final Color backgroundColor;
|
|
final double pendingPercentage;
|
|
|
|
const _GaugeChartPlaceholder({
|
|
required this.backgroundColor,
|
|
required this.pendingPercentage,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 120,
|
|
height: 80,
|
|
child: Stack(
|
|
children: [
|
|
CustomPaint(
|
|
size: const Size(120, 70),
|
|
painter: _SemiCirclePainter(
|
|
canvasColor: backgroundColor,
|
|
pendingPercentage: pendingPercentage,
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: FittedBox(
|
|
child: MyText.bodySmall('RISK LEVEL', fontWeight: 600),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SemiCirclePainter extends CustomPainter {
|
|
final Color canvasColor;
|
|
final double pendingPercentage;
|
|
|
|
_SemiCirclePainter(
|
|
{required this.canvasColor, required this.pendingPercentage});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final rect = Rect.fromCircle(
|
|
center: Offset(size.width / 2, size.height),
|
|
radius: size.width / 2,
|
|
);
|
|
|
|
const double arc = 3.14159;
|
|
final double pendingSweep = arc * pendingPercentage;
|
|
final double collectedSweep = arc * (1 - pendingPercentage);
|
|
|
|
final backgroundPaint = Paint()
|
|
..color = Colors.black.withOpacity(0.1)
|
|
..strokeWidth = 10
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawArc(rect, arc, arc, false, backgroundPaint);
|
|
|
|
final pendingPaint = Paint()
|
|
..strokeWidth = 10
|
|
..style = PaintingStyle.stroke
|
|
..shader = const LinearGradient(
|
|
colors: [Colors.orange, Colors.red],
|
|
).createShader(rect);
|
|
canvas.drawArc(rect, arc, pendingSweep, false, pendingPaint);
|
|
|
|
final collectedPaint = Paint()
|
|
..color = Colors.green
|
|
..strokeWidth = 10
|
|
..style = PaintingStyle.stroke;
|
|
canvas.drawArc(
|
|
rect, arc + pendingSweep, collectedSweep, false, collectedPaint);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
// AGING BUCKET
|
|
class AgingBucketData {
|
|
final String title;
|
|
final double amount;
|
|
final Color color;
|
|
final int invoiceCount; // ADDED
|
|
|
|
// UPDATED CONSTRUCTOR
|
|
AgingBucketData(this.title, this.amount, this.color, this.invoiceCount);
|
|
}
|
|
|
|
class _AgingStackedBar extends StatelessWidget {
|
|
final List<AgingBucketData> buckets;
|
|
final double totalOutstanding;
|
|
|
|
const _AgingStackedBar({
|
|
required this.buckets,
|
|
required this.totalOutstanding,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (totalOutstanding == 0) {
|
|
return Container(
|
|
height: 16,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[200],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Center(
|
|
child: MyText.bodySmall('No Outstanding Collections',
|
|
color: Colors.black54),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ClipRRect(
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Row(
|
|
children: buckets.where((b) => b.amount > 0).map((bucket) {
|
|
final flexValue = bucket.amount / totalOutstanding;
|
|
return Expanded(
|
|
flex: (flexValue * 1000).toInt(),
|
|
child: Container(height: 16, color: bucket.color),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|