672 lines
18 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|