marco.pms.mobileapp/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart
Vaibhav Surve 717f0c92af feat: Add CompactPurchaseInvoiceDashboard widget and integrate into dashboard screen
- Implemented a new widget for displaying purchase invoice metrics with internal dummy data.
- Integrated the CompactPurchaseInvoiceDashboard into the dashboard screen layout.
- Updated imports in dashboard_screen.dart to include the new purchase invoice dashboard widget.
2025-12-04 17:54:48 +05:30

858 lines
25 KiB
Dart

// lib/widgets/purchase_invoice_dashboard.dart
import 'dart:convert';
import 'package:flutter/material.dart';
/// =======================
/// INTERNAL DUMMY DATA
/// =======================
const String _purchaseInvoiceDummyData = '''
{
"success": true,
"message": "Purchase invoice list fetched successfully.",
"data": {
"currentPage": 1,
"pageSize": 20,
"totalPages": 1,
"totalCount": 6,
"hasPrevious": false,
"hasNext": false,
"data": [
{
"id": "0950d08d-a98e-43e5-a414-3de1071d0615",
"title": "Purchase of Booster Pump",
"proformaInvoiceAmount": 100000.0,
"project": { "name": "Kohinoor Sportsville" },
"supplier": { "name": "Fire Safety Solutions" },
"status": { "displayName": "Draft" },
"totalAmount": 0.0
},
{
"id": "c8828c81-d943-4f94-8c4f-f5e985ef5eb0",
"title": "Fire Panel Purchase",
"proformaInvoiceAmount": 50000.0,
"project": { "name": "Kohinoor Sportsville" },
"supplier": { "name": "Fire Safety Solutions" },
"status": { "displayName": "Draft" },
"totalAmount": 0.0
},
{
"id": "8bbe811f-b0fd-4b36-8c0c-3df4dd0f8aee",
"title": "Fire Pump Purchase",
"proformaInvoiceAmount": 30000.0,
"project": { "name": "Kohinoor Sportsville" },
"supplier": { "name": "Fire Safety Solutions" },
"status": { "displayName": "Draft" },
"totalAmount": 0.0
},
{
"id": "b485a0e8-1955-44a4-8062-e2004b4d4ada",
"title": "Pipe purchase at Lodha",
"proformaInvoiceAmount": 20000.0,
"project": { "name": "LODHA GIARDINO KHARADI FAPA" },
"supplier": { "name": "Elite Tech Services" },
"status": { "displayName": "Draft" },
"totalAmount": 20000.0
},
{
"id": "67406cdc-2b16-4290-a333-adc4fe7c0f70",
"title": "Pipe purchase For ",
"proformaInvoiceAmount": 10000.0,
"project": { "name": "Raja Bahadur International Ltd." },
"supplier": { "name": "Elite Tech Services" },
"status": { "displayName": "Draft" },
"totalAmount": 9800.0
},
{
"id": "4b8b60cb-65ac-4eff-8d35-dc3fa2ac37aa",
"title": "Cement material supply for Basement work",
"proformaInvoiceAmount": 10000.0,
"project": { "name": "ANP Ultimas Wakad" },
"supplier": { "name": "Skyline Fire Safety Pvt Ltd" },
"status": { "displayName": "Draft" },
"totalAmount": 24000.0
}
]
},
"errors": null,
"statusCode": 200,
"timestamp": "2025-12-04T09:50:43.6663252Z"
}
''';
/// =======================
/// REDESIGNED PUBLIC DASHBOARD WIDGET
/// =======================
/// Parent does NOT need to pass any data.
/// Optionally, parent can pass a JSON string if desired later.
class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final String? jsonString;
const CompactPurchaseInvoiceDashboard({
super.key,
this.jsonString, // if null, internal dummy JSON is used
});
@override
Widget build(BuildContext context) {
final PurchaseInvoiceDashboardData data =
_parsePurchaseInvoiceDashboardData(
jsonString ?? _purchaseInvoiceDummyData);
final metrics = data.metrics;
const double mainPadding = 16.0;
const double spacing = 16.0;
const double smallSpacing = 8.0;
// Outer Container for a polished card effect
return Container(
padding: const EdgeInsets.all(mainPadding),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10), // Slightly rounder corners
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08), // Softer, more subtle shadow
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
const _DashboardHeader(),
const SizedBox(height: spacing),
// 1. Total Value Card (Dominant Metric)
_TotalValueCard(
totalProformaAmount: metrics.totalProformaAmount,
totalCount: metrics.totalCount,
),
const SizedBox(height: spacing),
// 2. Key Metrics Row (Condensed, Broker-style display)
_CondensedMetricsRow(
draftCount: metrics.draftCount,
avgInvoiceValue: metrics.avgInvoiceValue,
topSupplierName: metrics.topSupplierName,
spacing: smallSpacing,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
// 3. Status Breakdown (Donut Chart Style)
const _SectionTitle('Status Breakdown by Value'),
const SizedBox(height: smallSpacing),
_StatusDonutChart(
statusBuckets: metrics.statusBuckets,
totalAmount: metrics.totalProformaAmount,
),
const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing),
// 4. Top Projects Breakdown (Top 3)
const _SectionTitle('Top Projects by Proforma Value'),
const SizedBox(height: smallSpacing),
_ProjectBreakdown(
projects: metrics.projectBuckets.take(3).toList(),
totalAmount: metrics.totalProformaAmount,
spacing: smallSpacing,
),
],
),
);
}
}
/// =======================
/// INTERNAL PARSING
/// =======================
PurchaseInvoiceDashboardData _parsePurchaseInvoiceDashboardData(
String jsonStr,
) {
final Map<String, dynamic> root =
json.decode(jsonStr) as Map<String, dynamic>;
final List<dynamic> rawInvoices =
(root['data']?['data'] as List<dynamic>?) ?? const [];
final List<PurchaseInvoiceData> invoices = rawInvoices
.whereType<Map<String, dynamic>>()
.map(PurchaseInvoiceData.fromJson)
.toList();
final PurchaseInvoiceMetrics metrics =
PurchaseInvoiceMetricsCalculator().calculate(invoices);
return PurchaseInvoiceDashboardData(
invoices: invoices,
metrics: metrics,
);
}
/// Container object used internally
class PurchaseInvoiceDashboardData {
final List<PurchaseInvoiceData> invoices;
final PurchaseInvoiceMetrics metrics;
const PurchaseInvoiceDashboardData({
required this.invoices,
required this.metrics,
});
}
/// =======================
/// DATA MODELS
/// =======================
class PurchaseInvoiceData {
final String id;
final String title;
final double proformaInvoiceAmount;
final String supplierName;
final String projectName;
final String statusName;
const PurchaseInvoiceData({
required this.id,
required this.title,
required this.proformaInvoiceAmount,
required this.supplierName,
required this.projectName,
required this.statusName,
});
factory PurchaseInvoiceData.fromJson(Map<String, dynamic> json) {
final supplier = json['supplier'] as Map<String, dynamic>? ?? const {};
final project = json['project'] as Map<String, dynamic>? ?? const {};
final status = json['status'] as Map<String, dynamic>? ?? const {};
return PurchaseInvoiceData(
id: json['id']?.toString() ?? '',
title: json['title']?.toString() ?? '',
proformaInvoiceAmount:
(json['proformaInvoiceAmount'] as num?)?.toDouble() ?? 0.0,
supplierName: supplier['name']?.toString() ?? 'Unknown Supplier',
projectName: project['name']?.toString() ?? 'Unknown Project',
statusName: status['displayName']?.toString() ?? 'Unknown',
);
}
}
class StatusBucketData {
final String title;
final double amount;
final Color color;
final int count;
const StatusBucketData({
required this.title,
required this.amount,
required this.color,
required this.count,
});
}
class ProjectMetricData {
final String name;
final double amount;
const ProjectMetricData({
required this.name,
required this.amount,
});
}
class PurchaseInvoiceMetrics {
final double totalProformaAmount;
final int totalCount;
final int draftCount;
final String topSupplierName;
final double topSupplierAmount;
final List<StatusBucketData> statusBuckets;
final List<ProjectMetricData> projectBuckets;
final double avgInvoiceValue;
const PurchaseInvoiceMetrics({
required this.totalProformaAmount,
required this.totalCount,
required this.draftCount,
required this.topSupplierName,
required this.topSupplierAmount,
required this.statusBuckets,
required this.projectBuckets,
required this.avgInvoiceValue,
});
}
/// =======================
/// METRICS CALCULATOR
/// =======================
class PurchaseInvoiceMetricsCalculator {
PurchaseInvoiceMetrics calculate(List<PurchaseInvoiceData> invoices) {
final double totalProformaAmount =
invoices.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
final int totalCount = invoices.length;
final int draftCount =
invoices.where((item) => item.statusName == 'Draft').length;
final Map<String, double> supplierTotals = <String, double>{};
for (final invoice in invoices) {
supplierTotals.update(
invoice.supplierName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final MapEntry<String, double>? topSupplierEntry = supplierTotals
.entries.isEmpty
? null
: supplierTotals.entries.reduce((a, b) => a.value > b.value ? a : b);
final String topSupplierName = topSupplierEntry?.key ?? 'N/A';
final double topSupplierAmount = topSupplierEntry?.value ?? 0.0;
final Map<String, double> projectTotals = <String, double>{};
for (final invoice in invoices) {
projectTotals.update(
invoice.projectName,
(value) => value + invoice.proformaInvoiceAmount,
ifAbsent: () => invoice.proformaInvoiceAmount,
);
}
final List<ProjectMetricData> projectBuckets = projectTotals.entries
.map((e) => ProjectMetricData(name: e.key, amount: e.value))
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
final Map<String, List<PurchaseInvoiceData>> statusGroups =
<String, List<PurchaseInvoiceData>>{};
for (final invoice in invoices) {
statusGroups.putIfAbsent(
invoice.statusName,
() => <PurchaseInvoiceData>[],
);
statusGroups[invoice.statusName]!.add(invoice);
}
final List<StatusBucketData> statusBuckets = statusGroups.entries.map(
(entry) {
final double statusTotal = entry.value
.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount);
return StatusBucketData(
title: entry.key,
amount: statusTotal,
color: getColorForStatus(entry.key),
count: entry.value.length,
);
},
).toList();
final double avgInvoiceValue =
totalCount > 0 ? totalProformaAmount / totalCount : 0.0;
return PurchaseInvoiceMetrics(
totalProformaAmount: totalProformaAmount,
totalCount: totalCount,
draftCount: draftCount,
topSupplierName: topSupplierName,
topSupplierAmount: topSupplierAmount,
statusBuckets: statusBuckets,
projectBuckets: projectBuckets,
avgInvoiceValue: avgInvoiceValue,
);
}
}
/// =======================
/// UTILITIES
/// =======================
Color _getProjectColor(String name) {
final int hash = name.hashCode;
const List<Color> colors = <Color>[
Color(0xFF42A5F5), // Blue
Color(0xFF66BB6A), // Green
Color(0xFFFFA726), // Orange
Color(0xFFEC407A), // Pink
Color(0xFF7E57C2), // Deep Purple
Color(0xFF26C6DA), // Cyan
Color(0xFFFFEE58), // Yellow
];
return colors[hash.abs() % colors.length];
}
Color getColorForStatus(String status) {
switch (status) {
case 'Draft':
return Colors.blueGrey;
case 'Pending Approval':
return Colors.orange;
case 'Approved':
return Colors.green;
case 'Paid':
return Colors.blue;
default:
return Colors.grey;
}
}
/// =======================
/// REDESIGNED INTERNAL UI WIDGETS
/// =======================
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle(this.title);
@override
Widget build(BuildContext context) {
return Text(
title,
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 14,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
),
);
}
}
class _DashboardHeader extends StatelessWidget {
const _DashboardHeader();
@override
Widget build(BuildContext context) {
return const Text(
'Purchase Invoice Dashboard ',
style: TextStyle(
color: Colors.black,
fontSize: 20,
fontWeight: FontWeight.w700,
),
);
}
}
// Total Value Card - Refined Style
class _TotalValueCard extends StatelessWidget {
final double totalProformaAmount;
final int totalCount;
const _TotalValueCard({
required this.totalProformaAmount,
required this.totalCount,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD), // Lighter Blue
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFBBDEFB), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'TOTAL PROFORMA VALUE (₹)',
style: TextStyle(
color: Colors.blue.shade800,
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1.0,
),
),
Icon(
Icons.account_balance_wallet_outlined,
color: Colors.blue.shade700,
size: 20,
),
],
),
const SizedBox(height: 8),
Text(
// Format number with commas if needed for large values
totalProformaAmount.toStringAsFixed(0),
style: const TextStyle(
color: Colors.black,
fontSize: 32,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
'Over $totalCount Total Invoices',
style: TextStyle(
color: Colors.blueGrey.shade600,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
// Condensed Metrics Row - Replaces the GridView
class _CondensedMetricsRow extends StatelessWidget {
final int draftCount;
final double avgInvoiceValue;
final String topSupplierName;
final double spacing;
const _CondensedMetricsRow({
required this.draftCount,
required this.avgInvoiceValue,
required this.topSupplierName,
required this.spacing,
});
@override
Widget build(BuildContext context) {
// Only showing 3 key metrics in a row for a tighter feel
return Row(
children: [
Expanded(
child: _CondensedMetricCard(
title: 'Drafts',
value: draftCount.toString(),
caption: 'To Complete',
color: Colors.orange.shade700,
icon: Icons.edit_note_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Avg. Value',
value: '${avgInvoiceValue.toStringAsFixed(0)}',
caption: 'Per Invoice',
color: Colors.purple.shade700,
icon: Icons.calculate_outlined,
),
),
SizedBox(width: spacing),
Expanded(
child: _CondensedMetricCard(
title: 'Top Supplier',
value: topSupplierName,
caption: 'By Value',
color: Colors.green.shade700,
icon: Icons.business_center_outlined,
),
),
],
);
}
}
// Condensed Metric Card - Small, impactful display
class _CondensedMetricCard extends StatelessWidget {
final String title;
final String value;
final String caption;
final Color color;
final IconData icon;
const _CondensedMetricCard({
required this.title,
required this.value,
required this.caption,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withOpacity(0.15), width: 1),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 4),
Expanded(
child: Text(
title,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 6),
Text(
value,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
Text(
caption,
style: TextStyle(
color: Colors.grey.shade500,
fontSize: 9,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
// Status Breakdown (Donut Chart + Legend) - Stronger Visualization
class _StatusDonutChart extends StatelessWidget {
final List<StatusBucketData> statusBuckets;
final double totalAmount;
const _StatusDonutChart({
required this.statusBuckets,
required this.totalAmount,
});
@override
Widget build(BuildContext context) {
final List<StatusBucketData> activeBuckets = statusBuckets
.where((b) => b.amount > 0)
.toList()
..sort((a, b) => b.amount.compareTo(a.amount));
if (activeBuckets.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'No active invoices to display status breakdown.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
);
}
// Determine the percentage of the largest bucket for the center text
final double mainPercentage =
totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0;
// Placeholder for Donut Chart (requires external package like fl_chart for true pie/donut chart)
// We simulate the key visual hierarchy here:
//
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Simulated Donut Chart (Center Focus)
Container(
width: 120,
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: activeBuckets.first.color.withOpacity(0.5), width: 6),
color: activeBuckets.first.color.withOpacity(0.05),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${activeBuckets.first.title}', // Top status name
style: TextStyle(
fontSize: 10,
color: activeBuckets.first.color,
fontWeight: FontWeight.bold,
),
),
Text(
'${(mainPercentage * 100).toStringAsFixed(0)}%',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: Colors.black87,
),
),
],
),
),
const SizedBox(width: 16),
// Legend/Details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: activeBuckets.map((bucket) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 8,
height: 8,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: bucket.color,
shape: BoxShape.circle,
),
),
Expanded(
child: Text(
'${bucket.title} (${bucket.count})',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade800,
fontWeight: FontWeight.w500,
),
),
),
Text(
'${bucket.amount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: bucket.color.withOpacity(0.9),
),
),
],
),
);
}).toList(),
),
),
],
);
}
}
// Project Breakdown - Denser and with clearer value
class _ProjectBreakdown extends StatelessWidget {
final List<ProjectMetricData> projects;
final double totalAmount;
final double spacing;
const _ProjectBreakdown({
required this.projects,
required this.totalAmount,
required this.spacing,
});
@override
Widget build(BuildContext context) {
if (projects.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'No project data available.',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
);
}
return Column(
children: projects.map((project) {
final double percentage =
totalAmount > 0 ? (project.amount / totalAmount) : 0.0;
final Color color = _getProjectColor(project.name);
final String percentageText = (percentage * 100).toStringAsFixed(1);
return Padding(
padding: EdgeInsets.only(bottom: spacing),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
project.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 2),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 4, // Smaller bar height
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${project.amount.toStringAsFixed(0)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: color.withOpacity(0.9),
),
),
Text(
'$percentageText%',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
],
),
);
}).toList(),
);
}
}