marco.pms.mobileapp/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart
2025-12-05 10:53:36 +05:30

672 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart';
// --- MAIN WIDGET FILE ---
class CollectionsHealthWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Derived Metrics from the JSON Analysis:
const double totalDue = 34190.0;
const double totalCollected = 5000.0;
const double totalValue = totalDue + totalCollected;
// Calculate Pending Percentage for Gauge
final double pendingPercentage =
totalValue > 0 ? totalDue / totalValue : 0.0;
// 1. MAIN CARD CONTAINER (White Theme)
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// 1. HEADER
_buildHeader(),
const SizedBox(height: 20),
// 2. MAIN CONTENT ROW (Layout)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Left Section: Gauge Chart, Due Amount, & Timelines
Expanded(
flex: 5,
child: _buildLeftChartSection(
totalDue: totalDue,
pendingPercentage: pendingPercentage,
),
),
const SizedBox(width: 16),
// Right Section: Metric Cards
Expanded(
flex: 4,
child: _buildRightMetricsSection(
totalCollected: totalCollected,
),
),
],
),
const SizedBox(height: 20),
// 3. AGING ANALYSIS SECTION
_buildAgingAnalysis(),
],
),
);
}
// --- HELPER METHOD 1: HEADER ---
Widget _buildHeader() {
return Row(mainAxisAlignment: MainAxisAlignment.start, 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,
),
],
),
),
]);
}
// --- HELPER METHOD 2: LEFT SECTION (CHARTS) ---
Widget _buildLeftChartSection({
required double totalDue,
required double pendingPercentage,
}) {
// Format the percentage for display
String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0);
// Use the derived totalCollected for a better context
const double totalCollected = 5000.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Top: Gauge Chart
Row(
children: <Widget>[
_GaugeChartPlaceholder(
backgroundColor: Colors.white,
pendingPercentage: pendingPercentage,
),
const SizedBox(width: 12),
],
),
const SizedBox(height: 20),
// Total Due + Summary
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%) • ₹${totalCollected.toStringAsFixed(0)} Collected',
color: Colors.black54,
),
],
),
),
],
),
const SizedBox(height: 20),
// Bottom: Timeline Charts (Trend Analysis)
Row(
children: <Widget>[
// Expected Collections Timeline (Bar Chart Placeholder)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodySmall(
'Expected Collections Trend',
),
const SizedBox(height: 8),
const _TimelineChartPlaceholder(
isBar: true,
barColor: Color(0xFF2196F3),
),
MyText.bodySmall(
'Week 16 Nov 2025',
color: Colors.black54,
),
],
),
),
const SizedBox(width: 10),
// Collection Rate Trend (Area Chart Placeholder)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodySmall(
'Collection Rate Trend',
),
const SizedBox(height: 8),
const _TimelineChartPlaceholder(
isBar: false,
areaColor: Color(0xFF4CAF50),
),
MyText.bodySmall(
'Week 14 Nov 2025',
color: Colors.black54,
),
],
),
),
],
),
],
);
}
// --- HELPER METHOD 3: RIGHT SECTION (METRICS) ---
Widget _buildRightMetricsSection({
required double totalCollected,
}) {
return Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
// Metric Card 1: Top Client
_buildMetricCard(
title: 'Top Client Balance',
value: 'Peninsula Land Limited',
subValue: '₹34,190',
valueColor: const Color(0xFFF44336), // Red (Pending/Due)
isDetailed: true,
),
const SizedBox(height: 10),
// Metric Card 2: Total Collected (YTD)
_buildMetricCard(
title: 'Total Collected (YTD)',
value: '${totalCollected.toStringAsFixed(0)}',
subValue: 'Collected',
valueColor: const Color(0xFF4CAF50), // Green (Positive Value)
isDetailed: false,
),
const SizedBox(height: 10),
// Metric Card 3: DSO
_buildMetricCard(
title: 'Days Sales Outstanding (DSO)',
value: '45 Days',
subValue: '↑ 5 Days',
valueColor: const Color(0xFFFF9800), // Orange (Needs improvement)
isDetailed: false,
),
const SizedBox(height: 10),
// Metric Card 4: Bad Debt Ratio
_buildMetricCard(
title: 'Bad Debt Ratio',
value: '0.8%',
subValue: '↓ 0.2%',
valueColor: const Color(0xFF4CAF50), // Green (Positive Change)
isDetailed: false,
),
],
);
}
// --- HELPER METHOD 4: METRIC CARD WIDGET ---
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,
),
],
),
],
),
);
}
// --- NEW HELPER METHOD: AGING ANALYSIS ---
Widget _buildAgingAnalysis() {
// Hardcoded data
const double due0to20Days = 0.0;
const double due20to45Days = 34190.0;
const double due45to90Days = 0.0;
const double dueOver90Days = 0.0;
final double totalOutstanding =
due0to20Days + due20to45Days + due45to90Days + dueOver90Days;
// Define buckets with their risk color
final List<AgingBucketData> buckets = [
AgingBucketData(
'0-20 Days',
due0to20Days,
const Color(0xFF4CAF50), // Green (Low Risk)
),
AgingBucketData(
'20-45 Days',
due20to45Days,
const Color(0xFFFF9800), // Orange (Medium Risk)
),
AgingBucketData(
'45-90 Days',
due45to90Days,
const Color(0xFFF44336).withOpacity(0.7), // Light Red
),
AgingBucketData(
'> 90 Days',
dueOver90Days,
const Color(0xFFF44336), // Dark Red
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
MyText.bodyMedium(
'Outstanding Collections Aging Analysis',
fontWeight: 700,
),
const SizedBox(height: 10),
// Stacked bar visualization
_AgingStackedBar(
buckets: buckets,
totalOutstanding: totalOutstanding,
),
const SizedBox(height: 15),
// Legend / Bucket details
Wrap(
spacing: 12,
runSpacing: 8,
children: buckets
.map(
(bucket) => _buildAgingLegendItem(
bucket.title,
bucket.amount,
bucket.color,
),
)
.toList(),
),
],
);
}
// Legend item for aging buckets
Widget _buildAgingLegendItem(String title, double amount, Color color) {
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)}',
),
],
);
}
}
// --- CUSTOM PAINTERS / PLACEHOLDERS ---
// Placeholder for the Semi-Circle 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: [
// BACKGROUND GAUGE
CustomPaint(
size: const Size(120, 70),
painter: _SemiCirclePainter(
canvasColor: backgroundColor,
pendingPercentage: pendingPercentage,
),
),
// CENTER TEXT
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: FittedBox(
child: MyText.bodySmall(
'RISK LEVEL',
fontWeight: 600,
),
),
),
),
],
),
);
}
}
// Painter for the semi-circular gauge chart visualization
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 totalArc = 3.14159;
final double pendingSweepAngle = totalArc * pendingPercentage;
final double collectedSweepAngle = totalArc * (1.0 - pendingPercentage);
// Background Arc
final backgroundPaint = Paint()
..color = Colors.black.withOpacity(0.1)
..style = PaintingStyle.stroke
..strokeWidth = 10;
canvas.drawArc(rect, totalArc, totalArc, false, backgroundPaint);
// Pending Arc
final pendingPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 10
..shader = const LinearGradient(
colors: [
Color(0xFFFF9800),
Color(0xFFF44336),
],
).createShader(rect);
canvas.drawArc(rect, totalArc, pendingSweepAngle, false, pendingPaint);
// Collected Arc
final collectedPaint = Paint()
..color = const Color(0xFF4CAF50)
..style = PaintingStyle.stroke
..strokeWidth = 10;
canvas.drawArc(
rect,
totalArc + pendingSweepAngle,
collectedSweepAngle,
false,
collectedPaint,
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// Placeholder for the Bar/Area Charts
class _TimelineChartPlaceholder extends StatelessWidget {
final bool isBar;
final Color? barColor;
final Color? areaColor;
const _TimelineChartPlaceholder({
required this.isBar,
this.barColor,
this.areaColor,
});
@override
Widget build(BuildContext context) {
return Container(
height: 50,
width: double.infinity,
color: Colors.transparent,
child: isBar
? _BarChartVisual(barColor: barColor!)
: _AreaChartVisual(areaColor: areaColor!),
);
}
}
class _BarChartVisual extends StatelessWidget {
final Color barColor;
const _BarChartVisual({required this.barColor});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: const [
_Bar(0.4),
_Bar(0.7),
_Bar(1.0),
_Bar(0.6),
_Bar(0.8),
],
);
}
}
class _Bar extends StatelessWidget {
final double heightFactor;
const _Bar(this.heightFactor);
@override
Widget build(BuildContext context) {
// Bar color is taken from parent via DefaultTextStyle/Theme if needed;
// you can wrap with Theme if you want dynamic colors.
return Container(
width: 8,
height: 50 * heightFactor,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(5),
),
);
}
}
class _AreaChartVisual extends StatelessWidget {
final Color areaColor;
const _AreaChartVisual({required this.areaColor});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _AreaChartPainter(areaColor: areaColor),
size: const Size(double.infinity, 50),
);
}
}
class _AreaChartPainter extends CustomPainter {
final Color areaColor;
_AreaChartPainter({required this.areaColor});
@override
void paint(Canvas canvas, Size size) {
final points = [
Offset(0, size.height * 0.5),
Offset(size.width * 0.25, size.height * 0.8),
Offset(size.width * 0.5, size.height * 0.3),
Offset(size.width * 0.75, size.height * 0.9),
Offset(size.width, size.height * 0.4),
];
final path = Path()
..moveTo(points.first.dx, size.height)
..lineTo(points.first.dx, points.first.dy);
for (int i = 1; i < points.length; i++) {
path.lineTo(points[i].dx, points[i].dy);
}
path.lineTo(points.last.dx, size.height);
path.close();
final areaPaint = Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
areaColor.withOpacity(0.5),
areaColor.withOpacity(0.0),
],
).createShader(Rect.fromLTWH(0, 0, size.width, size.height))
..style = PaintingStyle.fill;
canvas.drawPath(path, areaPaint);
final linePaint = Paint()
..color = areaColor
..strokeWidth = 2
..style = PaintingStyle.stroke;
canvas.drawPath(Path()..addPolygon(points, false), linePaint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// --- DATA MODEL ---
class AgingBucketData {
final String title;
final double amount;
final Color color;
AgingBucketData(this.title, this.amount, this.color);
}
// --- STACKED BAR VISUAL ---
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,
),
),
);
}
final List<Widget> segments =
buckets.where((b) => b.amount > 0).map((bucket) {
final double flexValue = bucket.amount / totalOutstanding;
return Expanded(
flex: (flexValue * 100).toInt(),
child: Container(
height: 16,
color: bucket.color,
),
);
}).toList();
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: segments,
),
);
}
}