marco.pms.mobileapp/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart

459 lines
14 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';
// ===============================================================
// MAIN WIDGET
// ===============================================================
class CollectionsHealthWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GetBuilder<DashboardController>(
builder: (controller) {
final data = controller.collectionOverviewData.value;
final isLoading = controller.isCollectionOverviewLoading.value;
if (isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
);
}
if (data == null) {
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Center(
child: MyText.bodyMedium(
'No collection overview 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 + TREND PLACEHOLDERS)
// ===============================================================
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 SIDE METRICS
// ===============================================================
Widget _buildRightMetricsSection({
required CollectionOverviewData data,
required double dsoDays,
}) {
final double totalCollected = data.totalCollectedAmount;
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: '${totalCollected.toStringAsFixed(0)}',
subValue: 'Collected',
valueColor: Colors.green,
isDetailed: false,
),
],
);
}
// ===============================================================
// METRIC CARD UI
// ===============================================================
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 (DYNAMIC)
// ===============================================================
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 // Updated parameter
) {
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)' // Updated text
),
],
);
}
// ===============================================================
// COMMON BOX DECORATION
// ===============================================================
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(),
),
);
}
}