// 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 root = json.decode(jsonStr) as Map; final List rawInvoices = (root['data']?['data'] as List?) ?? const []; final List invoices = rawInvoices .whereType>() .map(PurchaseInvoiceData.fromJson) .toList(); final PurchaseInvoiceMetrics metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices); return PurchaseInvoiceDashboardData( invoices: invoices, metrics: metrics, ); } /// Container object used internally class PurchaseInvoiceDashboardData { final List 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 json) { final supplier = json['supplier'] as Map? ?? const {}; final project = json['project'] as Map? ?? const {}; final status = json['status'] as Map? ?? 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 statusBuckets; final List 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 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 supplierTotals = {}; for (final invoice in invoices) { supplierTotals.update( invoice.supplierName, (value) => value + invoice.proformaInvoiceAmount, ifAbsent: () => invoice.proformaInvoiceAmount, ); } final MapEntry? 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 projectTotals = {}; for (final invoice in invoices) { projectTotals.update( invoice.projectName, (value) => value + invoice.proformaInvoiceAmount, ifAbsent: () => invoice.proformaInvoiceAmount, ); } final List projectBuckets = projectTotals.entries .map((e) => ProjectMetricData(name: e.key, amount: e.value)) .toList() ..sort((a, b) => b.amount.compareTo(a.amount)); final Map> statusGroups = >{}; for (final invoice in invoices) { statusGroups.putIfAbsent( invoice.statusName, () => [], ); statusGroups[invoice.statusName]!.add(invoice); } final List 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 colors = [ 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 statusBuckets; final double totalAmount; const _StatusDonutChart({ required this.statusBuckets, required this.totalAmount, }); @override Widget build(BuildContext context) { final List 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 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), 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(), ); } }