From b5637fca0db6ade8b98c3a6e68ac1e6501c255c7 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 10:24:53 +0530 Subject: [PATCH 01/26] feat: P&L, stock valuation and debtor aging reports with charts --- lib/data/database/daos/reports_dao.dart | 176 ++++ .../reports/presentation/reports_screen.dart | 841 +++++++++++++----- lib/providers/database_provider.dart | 4 + lib/providers/reports_provider.dart | 17 + 4 files changed, 837 insertions(+), 201 deletions(-) create mode 100644 lib/data/database/daos/reports_dao.dart create mode 100644 lib/providers/reports_provider.dart diff --git a/lib/data/database/daos/reports_dao.dart b/lib/data/database/daos/reports_dao.dart new file mode 100644 index 0000000..a9dd10e --- /dev/null +++ b/lib/data/database/daos/reports_dao.dart @@ -0,0 +1,176 @@ +import 'package:drift/drift.dart'; + +import 'package:bms/data/database/app_database.dart'; + +class DailySales { + DailySales({required this.date, required this.revenue, required this.cogs}); + final DateTime date; + final double revenue; + final double cogs; + double get grossProfit => revenue - cogs; +} + +class StockValuationRow { + StockValuationRow({ + required this.name, + required this.qty, + required this.costPrice, + required this.value, + }); + final String name; + final double qty; + final double costPrice; + final double value; +} + +class DebtorAgingRow { + DebtorAgingRow({ + required this.customerId, + required this.name, + required this.balance, + required this.oldestUnpaidDate, + }); + final String customerId; + final String name; + final double balance; + final DateTime? oldestUnpaidDate; + + int get daysPastDue { + if (oldestUnpaidDate == null) return 0; + return DateTime.now().difference(oldestUnpaidDate!).inDays; + } + + // 0 = 0-30d, 1 = 31-60d, 2 = 61-90d, 3 = 90+d + int get agingBucket { + final d = daysPastDue; + if (d <= 30) return 0; + if (d <= 60) return 1; + if (d <= 90) return 2; + return 3; + } +} + +class ReportsDao { + ReportsDao(this._db); + final AppDatabase _db; + + static String _dateKey(DateTime dt) => + '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}'; + + Future> getDailySales(DateTime from, DateTime to) async { + // Invoice revenue (non-void) + final invoiceList = await (_db.select(_db.invoices) + ..where((i) => i.createdAt.isBetweenValues(from, to) & i.status.equals('void').not())) + .get(); + + // Invoice COGS: items × product cost price + final cogsQuery = _db.select(_db.invoiceItems).join([ + innerJoin(_db.invoices, _db.invoices.id.equalsExp(_db.invoiceItems.invoiceId)), + innerJoin(_db.products, _db.products.id.equalsExp(_db.invoiceItems.productId)), + ]); + cogsQuery.where( + _db.invoices.createdAt.isBetweenValues(from, to) & + _db.invoices.status.equals('void').not(), + ); + final cogsRows = await cogsQuery.get(); + + // Quick-sale revenue + final qsList = await (_db.select(_db.noInvoiceSales) + ..where((s) => s.createdAt.isBetweenValues(from, to))) + .get(); + + // Quick-sale COGS: qty × product cost price + final qsCogsQuery = _db.select(_db.noInvoiceSales).join([ + innerJoin(_db.products, _db.products.id.equalsExp(_db.noInvoiceSales.productId)), + ]); + qsCogsQuery.where(_db.noInvoiceSales.createdAt.isBetweenValues(from, to)); + final qsCogsRows = await qsCogsQuery.get(); + + // Merge into day buckets + final Map revByDay = {}; + final Map cogsByDay = {}; + + for (final inv in invoiceList) { + final k = _dateKey(inv.createdAt); + revByDay[k] = (revByDay[k] ?? 0) + inv.total; + } + for (final row in cogsRows) { + final inv = row.readTable(_db.invoices); + final item = row.readTable(_db.invoiceItems); + final product = row.readTable(_db.products); + final k = _dateKey(inv.createdAt); + cogsByDay[k] = (cogsByDay[k] ?? 0) + item.qty * product.costPrice; + } + for (final qs in qsList) { + final k = _dateKey(qs.createdAt); + revByDay[k] = (revByDay[k] ?? 0) + qs.qty * qs.price; + } + for (final row in qsCogsRows) { + final qs = row.readTable(_db.noInvoiceSales); + final product = row.readTable(_db.products); + final k = _dateKey(qs.createdAt); + cogsByDay[k] = (cogsByDay[k] ?? 0) + qs.qty * product.costPrice; + } + + // Fill every day in range (including days with no sales → 0) + final result = []; + var cursor = DateTime(from.year, from.month, from.day); + final end = DateTime(to.year, to.month, to.day); + while (!cursor.isAfter(end)) { + final k = _dateKey(cursor); + result.add(DailySales( + date: cursor, + revenue: revByDay[k] ?? 0, + cogs: cogsByDay[k] ?? 0, + )); + cursor = cursor.add(const Duration(days: 1)); + } + return result; + } + + Future> getStockValuation() async { + final query = _db.select(_db.products).join([ + leftOuterJoin(_db.stock, _db.stock.productId.equalsExp(_db.products.id)), + ]); + query.where(_db.products.isActive.equals(true)); + final rows = await query.get(); + + return (rows.map((row) { + final product = row.readTable(_db.products); + final stockLevel = row.readTableOrNull(_db.stock); + final qty = stockLevel?.qty ?? 0; + return StockValuationRow( + name: product.name, + qty: qty, + costPrice: product.costPrice, + value: product.costPrice * qty, + ); + }).where((r) => r.qty > 0).toList() + ..sort((a, b) => b.value.compareTo(a.value))); + } + + Future> getDebtorAging() async { + final debtors = await (_db.select(_db.customers) + ..where((c) => c.balance.isBiggerThanValue(0)) + ..orderBy([(c) => OrderingTerm.desc(c.balance)])) + .get(); + + final result = []; + for (final customer in debtors) { + final oldest = await (_db.select(_db.invoices) + ..where((i) => + i.customerId.equals(customer.id) & + i.status.isIn(['open', 'partial'])) + ..orderBy([(i) => OrderingTerm.asc(i.createdAt)]) + ..limit(1)) + .getSingleOrNull(); + result.add(DebtorAgingRow( + customerId: customer.id, + name: customer.name, + balance: customer.balance, + oldestUnpaidDate: oldest?.createdAt, + )); + } + return result; + } +} diff --git a/lib/features/reports/presentation/reports_screen.dart b/lib/features/reports/presentation/reports_screen.dart index 928e486..de85b13 100644 --- a/lib/features/reports/presentation/reports_screen.dart +++ b/lib/features/reports/presentation/reports_screen.dart @@ -1,164 +1,350 @@ +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_text_styles.dart'; -import '../../../core/utils/currency_utils.dart'; -import '../../../core/utils/date_utils.dart'; -import '../../../data/database/app_database.dart'; -import '../../../providers/cheques_provider.dart'; -import '../../../providers/dashboard_provider.dart'; -import '../../../providers/inventory_provider.dart'; +import 'package:bms/core/theme/app_colors.dart'; +import 'package:bms/core/theme/app_text_styles.dart'; +import 'package:bms/core/utils/currency_utils.dart'; +import 'package:bms/data/database/daos/reports_dao.dart'; +import 'package:bms/providers/reports_provider.dart'; +import 'package:bms/shared/widgets/bms_filter_bar.dart'; -class ReportsScreen extends ConsumerWidget { +class ReportsScreen extends ConsumerStatefulWidget { const ReportsScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final todaySalesAsync = ref.watch(todaySalesTotalProvider); - final upcomingChequesAsync = ref.watch(chequesUpcomingProvider); - final lowStockAsync = ref.watch(lowStockStreamProvider); + ConsumerState createState() => _ReportsScreenState(); +} + +class _ReportsScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final TabController _tabs; + + @override + void initState() { + super.initState(); + _tabs = TabController(length: 3, vsync: this); + } + @override + void dispose() { + _tabs.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Reports'), - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - onPressed: () { - ref.invalidate(todaySalesTotalProvider); - ref.invalidate(chequesUpcomingProvider); - ref.invalidate(lowStockStreamProvider); + bottom: TabBar( + controller: _tabs, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + indicatorColor: Colors.white, + tabs: const [ + Tab(text: 'P&L'), + Tab(text: 'Stock Value'), + Tab(text: 'Aging'), + ], + ), + ), + body: TabBarView( + controller: _tabs, + children: const [ + _PLTab(), + _StockTab(), + _AgingTab(), + ], + ), + ); + } +} + +// ─── P&L Tab ──────────────────────────────────────────────────────────────── + +class _PLTab extends ConsumerStatefulWidget { + const _PLTab(); + + @override + ConsumerState<_PLTab> createState() => _PLTabState(); +} + +class _PLTabState extends ConsumerState<_PLTab> { + late DateTimeRange _range; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _range = DateTimeRange( + start: DateTime(now.year, now.month, 1), + end: DateTime(now.year, now.month + 1, 1).subtract(const Duration(seconds: 1)), + ); + } + + @override + Widget build(BuildContext context) { + final async = ref.watch(dailySalesProvider(_range.start, _range.end)); + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: BmsDateRangeField( + start: _range.start, + end: _range.end, + onPick: (r) => setState(() => _range = r), + ), + ), + Expanded( + child: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (daily) { + final revenue = daily.fold(0, (s, d) => s + d.revenue); + final cogs = daily.fold(0, (s, d) => s + d.cogs); + final grossProfit = revenue - cogs; + final margin = revenue > 0 ? grossProfit / revenue * 100 : 0.0; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + _SummaryGrid( + items: [ + ( + label: 'Revenue', + value: CurrencyUtils.format(revenue), + color: AppColors.success, + icon: Icons.trending_up, + ), + ( + label: 'COGS', + value: CurrencyUtils.format(cogs), + color: AppColors.error, + icon: Icons.shopping_cart_outlined, + ), + ( + label: 'Gross Profit', + value: CurrencyUtils.format(grossProfit), + color: grossProfit >= 0 ? AppColors.primary : AppColors.error, + icon: Icons.account_balance_outlined, + ), + ( + label: 'Margin', + value: '${margin.toStringAsFixed(1)}%', + color: margin >= 20 ? AppColors.success : AppColors.warning, + icon: Icons.percent, + ), + ], + ), + const SizedBox(height: 24), + if (daily.any((d) => d.revenue > 0)) ...[ + Text('Daily Revenue', style: AppTextStyles.titleMedium), + const SizedBox(height: 12), + _PLChart(daily: daily), + ] else + _EmptyState( + icon: Icons.bar_chart_outlined, + message: 'No sales data for this period.', + ), + ], + ); }, ), + ), + ], + ); + } +} + +class _PLChart extends StatelessWidget { + const _PLChart({required this.daily}); + final List daily; + + static String _compact(double v) { + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}K'; + return v.toStringAsFixed(0); + } + + @override + Widget build(BuildContext context) { + final maxY = daily.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); + final barWidth = daily.length <= 14 ? 14.0 : 8.0; + + final barGroups = daily.asMap().entries.map((e) { + final idx = e.key; + final d = e.value; + return BarChartGroupData( + x: idx, + barRods: [ + BarChartRodData( + toY: d.revenue, + width: barWidth, + color: AppColors.primary, + borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + ), ], - ), - body: RefreshIndicator( - onRefresh: () async { - ref.invalidate(todaySalesTotalProvider); - ref.invalidate(chequesUpcomingProvider); - ref.invalidate(lowStockStreamProvider); - }, - child: ListView( - padding: const EdgeInsets.all(20), - children: [ - Text("Today's Summary - ${BmsDateUtils.formatDate(DateTime.now())}", style: AppTextStyles.titleLarge), - const SizedBox(height: 16), - // Today's sales card - todaySalesAsync.when( - loading: () => const _SummaryCardShimmer(label: "Today's Sales"), - error: (e, _) => _ErrorCard(label: "Today's Sales", error: e.toString()), - data: (total) => _SummaryCard( - label: "Today's Sales", - value: CurrencyUtils.format(total), - icon: Icons.receipt_long_outlined, - color: AppColors.success, - subtitle: 'Total invoiced today', - ), - ), - const SizedBox(height: 16), - // Low stock card - lowStockAsync.when( - loading: () => const _SummaryCardShimmer(label: 'Low Stock Items'), - error: (e, _) => _ErrorCard(label: 'Low Stock Items', error: e.toString()), - data: (items) => _SummaryCard( - label: 'Low Stock Items', - value: '${items.length}', - icon: Icons.warning_amber_outlined, - color: items.isEmpty ? AppColors.success : AppColors.warning, - subtitle: items.isEmpty ? 'All items are adequately stocked' : 'Items at or below reorder level', + ); + }).toList(); + + // Show a bottom label every N days to avoid crowding + final labelStep = daily.length <= 10 + ? 1 + : daily.length <= 20 + ? 2 + : 5; + + return SizedBox( + height: 220, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SizedBox( + width: (barWidth + 4) * daily.length + 80, + child: BarChart( + BarChartData( + maxY: maxY * 1.15, + barGroups: barGroups, + gridData: const FlGridData( + show: true, + drawVerticalLine: false, ), - ), - const SizedBox(height: 16), - // Upcoming cheques card - upcomingChequesAsync.when( - loading: () => const _SummaryCardShimmer(label: 'Cheques Due (7 days)'), - error: (e, _) => _ErrorCard(label: 'Cheques Due (7 days)', error: e.toString()), - data: (cheques) { - final total = cheques.fold(0, (s, c) => s + c.amount); - return _SummaryCard( - label: 'Cheques Due (7 days)', - value: '${cheques.length}', - icon: Icons.calendar_today_outlined, - color: cheques.isEmpty ? AppColors.success : AppColors.primary, - subtitle: cheques.isEmpty ? 'No cheques due soon' : 'Total: ${CurrencyUtils.format(total)}', - ); - }, - ), - const SizedBox(height: 32), - // Upcoming cheques list - upcomingChequesAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (cheques) { - if (cheques.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Upcoming Cheques', style: AppTextStyles.titleMedium), - const SizedBox(height: 12), - ...cheques.map((c) => _ChequeRow(cheque: c)), - ], - ); - }, - ), - const SizedBox(height: 32), - // Low stock list - lowStockAsync.when( - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - data: (items) { - if (items.isEmpty) return const SizedBox.shrink(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Low Stock Items', style: AppTextStyles.titleMedium), - const SizedBox(height: 12), - ...items.map((s) => _StockRow(stock: s)), - ], - ); - }, - ), - const SizedBox(height: 32), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.surfaceVariant, - borderRadius: BorderRadius.circular(12), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (_, _, rod, _) => BarTooltipItem( + CurrencyUtils.format(rod.toY), + AppTextStyles.bodySmall.copyWith(color: Colors.white), + ), + ), ), - child: const Text( - 'Charts and detailed reports (P&L, stock valuation, aging) will be available in Phase 4.', - style: AppTextStyles.bodySmall, - textAlign: TextAlign.center, + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (v, meta) => SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + _compact(v), + style: AppTextStyles.bodySmall, + ), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || + idx >= daily.length || + idx % labelStep != 0) { + return const SizedBox.shrink(); + } + final d = daily[idx].date; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + '${d.day}/${d.month}', + style: AppTextStyles.bodySmall, + ), + ); + }, + ), + ), ), ), - ], + ), ), ), ); } } -class _SummaryCard extends StatelessWidget { - const _SummaryCard({ - required this.label, - required this.value, - required this.icon, - required this.color, - required this.subtitle, - }); +// ─── Stock Valuation Tab ───────────────────────────────────────────────────── - final String label; - final String value; - final IconData icon; - final Color color; - final String subtitle; +class _StockTab extends ConsumerWidget { + const _StockTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(stockValuationProvider); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (rows) { + if (rows.isEmpty) { + return _EmptyState( + icon: Icons.inventory_2_outlined, + message: 'No stock on hand.', + ); + } + + final totalValue = rows.fold(0, (s, r) => s + r.value); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: rows.length + 1, + itemBuilder: (_, i) { + if (i == 0) { + return Column( + children: [ + _TotalValueCard( + totalValue: totalValue, + itemCount: rows.length, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Text('Product', + style: AppTextStyles.bodySmall)), + SizedBox( + width: 48, + child: Text('Qty', + style: AppTextStyles.bodySmall, + textAlign: TextAlign.end), + ), + SizedBox( + width: 80, + child: Text('Value', + style: AppTextStyles.bodySmall, + textAlign: TextAlign.end), + ), + ], + ), + ), + const Divider(height: 1), + ], + ); + } + final row = rows[i - 1]; + return _StockValueRow(row: row, maxValue: totalValue); + }, + ); + }, + ); + } +} + +class _TotalValueCard extends StatelessWidget { + const _TotalValueCard({required this.totalValue, required this.itemCount}); + final double totalValue; + final int itemCount; @override Widget build(BuildContext context) { return Card( - elevation: 1, child: Padding( padding: const EdgeInsets.all(20), child: Row( @@ -166,19 +352,24 @@ class _SummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: color.withOpacity(0.1), + color: AppColors.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), - child: Icon(icon, color: color, size: 28), + child: const Icon(Icons.inventory_2_outlined, + color: AppColors.primary, size: 28), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: AppTextStyles.bodySmall), - Text(value, style: AppTextStyles.titleLarge.copyWith(color: color)), - Text(subtitle, style: AppTextStyles.bodySmall), + const Text('Total Stock Value', + style: AppTextStyles.bodySmall), + Text(CurrencyUtils.format(totalValue), + style: AppTextStyles.titleLarge + .copyWith(color: AppColors.primary)), + Text('$itemCount products with stock', + style: AppTextStyles.bodySmall), ], ), ), @@ -189,105 +380,353 @@ class _SummaryCard extends StatelessWidget { } } -class _SummaryCardShimmer extends StatelessWidget { - const _SummaryCardShimmer({required this.label}); - final String label; +class _StockValueRow extends StatelessWidget { + const _StockValueRow({required this.row, required this.maxValue}); + final StockValuationRow row; + final double maxValue; @override Widget build(BuildContext context) { - return Card( - elevation: 1, - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( + final fraction = maxValue > 0 ? (row.value / maxValue).clamp(0.0, 1.0) : 0.0; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(row.name, + style: AppTextStyles.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis), + ), + SizedBox( + width: 48, + child: Text( + row.qty % 1 == 0 + ? row.qty.toInt().toString() + : row.qty.toStringAsFixed(2), + style: AppTextStyles.bodySmall, + textAlign: TextAlign.end, + ), + ), + SizedBox( + width: 80, + child: Text( + CurrencyUtils.format(row.value), + style: AppTextStyles.labelLarge + .copyWith(color: AppColors.primary), + textAlign: TextAlign.end, + ), + ), + ], + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: fraction, + backgroundColor: AppColors.border, + color: AppColors.primary.withValues(alpha: 0.5), + minHeight: 3, + borderRadius: BorderRadius.circular(2), + ), + const SizedBox(height: 6), + const Divider(height: 1), + ], + ), + ); + } +} + +// ─── Debtor Aging Tab ──────────────────────────────────────────────────────── + +class _AgingTab extends ConsumerWidget { + const _AgingTab(); + + static const _bucketLabels = ['0-30 days', '31-60 days', '61-90 days', '90+ days']; + static const _bucketColors = [ + AppColors.success, + AppColors.warning, + AppColors.error, + Color(0xFFB71C1C), + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(debtorAgingProvider); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (rows) { + if (rows.isEmpty) { + return _EmptyState( + icon: Icons.people_outline, + message: 'No outstanding balances.', + ); + } + + final total = rows.fold(0, (s, r) => s + r.balance); + final bucketAmounts = List.filled(4, 0.0); + for (final r in rows) { + bucketAmounts[r.agingBucket] += r.balance; + } + + return ListView( + padding: const EdgeInsets.all(16), children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: AppColors.border, - borderRadius: BorderRadius.circular(12), + // Summary card + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.warning_amber_outlined, + color: AppColors.warning, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Total Outstanding', + style: AppTextStyles.bodySmall), + Text(CurrencyUtils.format(total), + style: AppTextStyles.titleLarge + .copyWith(color: AppColors.warning)), + Text('${rows.length} customers', + style: AppTextStyles.bodySmall), + ], + ), + ), + ], + ), ), ), - const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: AppTextStyles.bodySmall), - const SizedBox(height: 6), - const SizedBox( - width: 80, - height: 20, - child: LinearProgressIndicator(), - ), - ], + const SizedBox(height: 20), + + // Pie chart + legend + Text('Balance by Age', style: AppTextStyles.titleMedium), + const SizedBox(height: 12), + _AgingChart( + bucketAmounts: bucketAmounts, + total: total, + labels: _bucketLabels, + colors: _bucketColors, ), + const SizedBox(height: 8), + _AgingLegend( + bucketAmounts: bucketAmounts, + labels: _bucketLabels, + colors: _bucketColors), + const SizedBox(height: 24), + + // Debtor list + Text('Customers', style: AppTextStyles.titleMedium), + const SizedBox(height: 8), + ...rows.map((r) => _DebtorRow(row: r, colors: _bucketColors, labels: _bucketLabels)), ], + ); + }, + ); + } +} + +class _AgingChart extends StatelessWidget { + const _AgingChart({ + required this.bucketAmounts, + required this.total, + required this.labels, + required this.colors, + }); + final List bucketAmounts; + final double total; + final List labels; + final List colors; + + @override + Widget build(BuildContext context) { + final nonZero = bucketAmounts.any((v) => v > 0); + if (!nonZero) return const SizedBox.shrink(); + + return SizedBox( + height: 180, + child: PieChart( + PieChartData( + sections: List.generate(4, (i) { + final value = bucketAmounts[i]; + if (value == 0) return null; + final pct = total > 0 ? value / total * 100 : 0.0; + return PieChartSectionData( + value: value, + color: colors[i], + radius: 60, + title: '${pct.toStringAsFixed(0)}%', + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ); + }).whereType().toList(), + centerSpaceRadius: 40, + sectionsSpace: 2, ), ), ); } } -class _ErrorCard extends StatelessWidget { - const _ErrorCard({required this.label, required this.error}); - final String label; - final String error; +class _AgingLegend extends StatelessWidget { + const _AgingLegend({ + required this.bucketAmounts, + required this.labels, + required this.colors, + }); + final List bucketAmounts; + final List labels; + final List colors; @override Widget build(BuildContext context) { - return Card( - color: AppColors.errorLight, - child: Padding( - padding: const EdgeInsets.all(16), - child: Text('$label: $error', style: AppTextStyles.bodySmall.copyWith(color: AppColors.error)), - ), + return Wrap( + spacing: 16, + runSpacing: 8, + children: List.generate(4, (i) { + if (bucketAmounts[i] == 0) return const SizedBox.shrink(); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: colors[i], + borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 4), + Text( + '${labels[i]} ${CurrencyUtils.format(bucketAmounts[i])}', + style: AppTextStyles.bodySmall, + ), + ], + ); + }), ); } } -class _ChequeRow extends StatelessWidget { - const _ChequeRow({required this.cheque}); - final Cheque cheque; +class _DebtorRow extends StatelessWidget { + const _DebtorRow( + {required this.row, required this.colors, required this.labels}); + final DebtorAgingRow row; + final List colors; + final List labels; @override Widget build(BuildContext context) { + final bucket = row.agingBucket; return Card( margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - dense: true, - leading: Icon( - cheque.type == 'received' ? Icons.arrow_downward : Icons.arrow_upward, - color: cheque.type == 'received' ? AppColors.success : AppColors.error, - size: 20, - ), - title: Text(cheque.partyName, style: AppTextStyles.labelLarge), - subtitle: Text( - 'Due: ${BmsDateUtils.formatDate(cheque.dueDate)}', - style: AppTextStyles.bodySmall, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(row.name, style: AppTextStyles.labelLarge), + const SizedBox(height: 2), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: colors[bucket].withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + labels[bucket], + style: AppTextStyles.bodySmall + .copyWith(color: colors[bucket]), + ), + ), + ], + ), + ), + Text( + CurrencyUtils.format(row.balance), + style: AppTextStyles.titleMedium + .copyWith(color: colors[bucket]), + ), + ], ), - trailing: Text(CurrencyUtils.format(cheque.amount), style: AppTextStyles.titleMedium), ), ); } } -class _StockRow extends StatelessWidget { - const _StockRow({required this.stock}); - final StockLevel stock; +// ─── Shared helpers ────────────────────────────────────────────────────────── + +class _SummaryGrid extends StatelessWidget { + const _SummaryGrid({required this.items}); + final List<({String label, String value, Color color, IconData icon})> items; @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - dense: true, - leading: const Icon(Icons.inventory_2_outlined, color: AppColors.warning, size: 20), - title: Text(stock.productId, style: AppTextStyles.labelLarge), - trailing: Text( - 'Qty: ${stock.qty.toStringAsFixed(0)}', - style: AppTextStyles.titleMedium.copyWith(color: AppColors.warning), - ), + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 1.6, + children: items.map((item) { + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(item.icon, color: item.color, size: 16), + const SizedBox(width: 4), + Text(item.label, + style: AppTextStyles.bodySmall), + ], + ), + const Spacer(), + Text( + item.value, + style: AppTextStyles.titleMedium + .copyWith(color: item.color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }).toList(), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.icon, required this.message}); + final IconData icon; + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: AppColors.textSecondary), + const SizedBox(height: 12), + Text(message, style: AppTextStyles.bodySmall), + ], ), ); } diff --git a/lib/providers/database_provider.dart b/lib/providers/database_provider.dart index 96afdf2..192f8d0 100644 --- a/lib/providers/database_provider.dart +++ b/lib/providers/database_provider.dart @@ -8,6 +8,7 @@ import '../data/database/daos/customers_dao.dart'; import '../data/database/daos/inventory_dao.dart'; import '../data/database/daos/invoices_dao.dart'; import '../data/database/daos/petty_cash_dao.dart'; +import '../data/database/daos/reports_dao.dart'; import '../data/database/daos/returns_dao.dart'; import '../data/database/daos/suppliers_dao.dart'; import '../data/database/daos/users_dao.dart'; @@ -47,3 +48,6 @@ AuditLogDao auditLogDao(Ref ref) => ref.watch(appDatabaseProvider).auditLogDao; @Riverpod(keepAlive: true) ReturnsDao returnsDao(Ref ref) => ref.watch(appDatabaseProvider).returnsDao; + +@Riverpod(keepAlive: true) +ReportsDao reportsDao(Ref ref) => ReportsDao(ref.watch(appDatabaseProvider)); diff --git a/lib/providers/reports_provider.dart b/lib/providers/reports_provider.dart new file mode 100644 index 0000000..3ca70ce --- /dev/null +++ b/lib/providers/reports_provider.dart @@ -0,0 +1,17 @@ +import 'package:bms/data/database/daos/reports_dao.dart'; +import 'package:bms/providers/database_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'reports_provider.g.dart'; + +@riverpod +Future> dailySales(Ref ref, DateTime from, DateTime to) => + ref.read(reportsDaoProvider).getDailySales(from, to); + +@riverpod +Future> stockValuation(Ref ref) => + ref.read(reportsDaoProvider).getStockValuation(); + +@riverpod +Future> debtorAging(Ref ref) => + ref.read(reportsDaoProvider).getDebtorAging(); From a605602bc198ce40d724dd2ef5895e96bbca1169 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 10:24:57 +0530 Subject: [PATCH 02/26] feat: 7-day trend chart, MTD card and payment mix donut on dashboard --- .../presentation/dashboard_screen.dart | 353 ++++++++++++++++-- lib/providers/dashboard_provider.dart | 61 ++- 2 files changed, 366 insertions(+), 48 deletions(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 98d7647..20e036c 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,14 +1,17 @@ +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; -import '../../../core/router/app_router.dart'; -import '../../../core/theme/app_colors.dart'; -import '../../../core/theme/app_text_styles.dart'; -import '../../../core/utils/currency_utils.dart'; -import '../../../core/utils/date_utils.dart'; -import '../../../providers/dashboard_provider.dart'; -import '../../../shared/widgets/stat_card.dart'; +import 'package:bms/core/router/app_router.dart'; +import 'package:bms/core/theme/app_colors.dart'; +import 'package:bms/core/theme/app_text_styles.dart'; +import 'package:bms/core/utils/currency_utils.dart'; +import 'package:bms/core/utils/date_utils.dart'; +import 'package:bms/data/database/daos/reports_dao.dart'; +import 'package:bms/providers/dashboard_provider.dart'; +import 'package:bms/shared/widgets/stat_card.dart'; class DashboardScreen extends ConsumerWidget { const DashboardScreen({super.key}); @@ -19,7 +22,7 @@ class DashboardScreen extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Text('Dashboard - ${BmsDateUtils.formatDate(DateTime.now())}'), + title: Text('Dashboard — ${BmsDateUtils.formatDate(DateTime.now())}'), ), body: stats.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -27,12 +30,13 @@ class DashboardScreen extends ConsumerWidget { data: (s) => RefreshIndicator( onRefresh: () => ref.refresh(dashboardStatsProvider.future), child: ListView( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.all(16), children: [ + // ── Stat Cards ────────────────────────────────────────────── GridView.extent( maxCrossAxisExtent: 300, - mainAxisSpacing: 16, - crossAxisSpacing: 16, + mainAxisSpacing: 12, + crossAxisSpacing: 12, childAspectRatio: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), @@ -52,7 +56,7 @@ class DashboardScreen extends ConsumerWidget { onTap: () => context.go(AppRoutes.inventory), ), StatCard( - label: 'Cheques Due (7 days)', + label: 'Cheques Due (7d)', value: '${s.chequesThisWeek}', icon: Icons.calendar_today_outlined, color: AppColors.primary, @@ -67,27 +71,57 @@ class DashboardScreen extends ConsumerWidget { ), ], ), + + const SizedBox(height: 20), + + // ── MTD Sales vs Last Month ────────────────────────────────── + _MtdCard( + mtd: s.mtdSales, + lastMonth: s.lastMonthSales, + growthPct: s.mtdGrowthPct, + ), + + const SizedBox(height: 24), + + // ── 7-Day Revenue Chart ────────────────────────────────────── + Text('7-Day Revenue', style: AppTextStyles.titleMedium), + const SizedBox(height: 12), + _WeeklyBarChart(trend: s.weeklyTrend), + + // ── Payment Mix ────────────────────────────────────────────── + if (s.paymentMix.isNotEmpty) ...[ + const SizedBox(height: 24), + Text('Payment Mix — This Month', + style: AppTextStyles.titleMedium), + const SizedBox(height: 12), + _PaymentMixChart(mix: s.paymentMix), + ], + + const SizedBox(height: 24), + + // ── Recent Invoices ────────────────────────────────────────── if (s.recentInvoices.isNotEmpty) ...[ - const SizedBox(height: 32), Text('Recent Invoices', style: AppTextStyles.titleMedium), const SizedBox(height: 12), - ...s.recentInvoices.map((inv) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: const Icon(Icons.receipt_outlined, - color: AppColors.primary), - title: Text(inv.invoiceNo, - style: AppTextStyles.labelLarge), - subtitle: Text( - BmsDateUtils.formatDateTime(inv.createdAt), - style: AppTextStyles.bodySmall), - trailing: Text( - CurrencyUtils.format(inv.total), - style: AppTextStyles.titleMedium.copyWith( - color: AppColors.success), - ), + ...s.recentInvoices.map( + (inv) => Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: const Icon(Icons.receipt_outlined, + color: AppColors.primary), + title: Text(inv.invoiceNo, + style: AppTextStyles.labelLarge), + subtitle: Text( + BmsDateUtils.formatDateTime(inv.createdAt), + style: AppTextStyles.bodySmall), + trailing: Text( + CurrencyUtils.format(inv.total), + style: AppTextStyles.titleMedium + .copyWith(color: AppColors.success), ), - )), + ), + ), + ), ], ], ), @@ -96,3 +130,264 @@ class DashboardScreen extends ConsumerWidget { ); } } + +// ── MTD Card ──────────────────────────────────────────────────────────────── + +class _MtdCard extends StatelessWidget { + const _MtdCard({ + required this.mtd, + required this.lastMonth, + required this.growthPct, + }); + final double mtd; + final double lastMonth; + final double growthPct; + + @override + Widget build(BuildContext context) { + final isPositive = growthPct >= 0; + final color = isPositive ? AppColors.success : AppColors.error; + final icon = isPositive ? Icons.trending_up : Icons.trending_down; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Month-to-Date Sales', + style: AppTextStyles.bodySmall), + const SizedBox(height: 4), + Text(CurrencyUtils.format(mtd), + style: AppTextStyles.titleLarge + .copyWith(color: AppColors.primary)), + if (lastMonth > 0) + Text( + 'vs ${CurrencyUtils.format(lastMonth)} last month', + style: AppTextStyles.bodySmall, + ), + ], + ), + ), + if (lastMonth > 0) + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: color, size: 18), + const SizedBox(width: 4), + Text( + '${growthPct.abs().toStringAsFixed(1)}%', + style: AppTextStyles.titleMedium.copyWith(color: color), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +// ── Weekly Bar Chart ───────────────────────────────────────────────────────── + +class _WeeklyBarChart extends StatelessWidget { + const _WeeklyBarChart({required this.trend}); + final List trend; + + static String _compact(double v) { + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}K'; + return v.toStringAsFixed(0); + } + + @override + Widget build(BuildContext context) { + if (trend.isEmpty) { + return const SizedBox( + height: 160, + child: Center( + child: Text('No sales in the last 7 days.', + style: AppTextStyles.bodySmall), + ), + ); + } + + final maxY = trend.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); + final fmt = DateFormat('EEE'); + + final groups = trend.asMap().entries.map((e) { + final d = e.value; + return BarChartGroupData( + x: e.key, + barRods: [ + BarChartRodData( + toY: d.revenue, + width: 28, + color: d.revenue > 0 ? AppColors.primary : AppColors.border, + borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + ), + ], + ); + }).toList(); + + return SizedBox( + height: 200, + child: BarChart( + BarChartData( + maxY: maxY > 0 ? maxY * 1.2 : 100, + barGroups: groups, + gridData: const FlGridData(show: true, drawVerticalLine: false), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (_, __, rod, ___) => BarTooltipItem( + CurrencyUtils.format(rod.toY), + AppTextStyles.bodySmall.copyWith(color: Colors.white), + ), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (v, meta) => SideTitleWidget( + axisSide: meta.axisSide, + child: Text(_compact(v), style: AppTextStyles.bodySmall), + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || idx >= trend.length) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + fmt.format(trend[idx].date), + style: AppTextStyles.bodySmall, + ), + ); + }, + ), + ), + ), + ), + ), + ); + } +} + +// ── Payment Mix Donut ──────────────────────────────────────────────────────── + +class _PaymentMixChart extends StatelessWidget { + const _PaymentMixChart({required this.mix}); + final Map mix; + + static const _colorMap = { + 'cash': AppColors.success, + 'card': AppColors.primary, + 'cheque': AppColors.warning, + 'credit': AppColors.error, + 'mixed': Color(0xFF7B1FA2), + }; + + Color _colorFor(String type) => + _colorMap[type.toLowerCase()] ?? AppColors.textSecondary; + + @override + Widget build(BuildContext context) { + final total = mix.values.fold(0, (s, v) => s + v); + final entries = mix.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + + return Row( + children: [ + SizedBox( + width: 160, + height: 160, + child: PieChart( + PieChartData( + sections: entries.map((e) { + final pct = total > 0 ? e.value / total * 100 : 0.0; + return PieChartSectionData( + value: e.value, + color: _colorFor(e.key), + radius: 52, + title: '${pct.toStringAsFixed(0)}%', + titleStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + showTitle: pct >= 8, + ); + }).toList(), + centerSpaceRadius: 32, + sectionsSpace: 2, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map((e) { + final pct = total > 0 ? e.value / total * 100 : 0.0; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _colorFor(e.key), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + e.key[0].toUpperCase() + e.key.substring(1), + style: AppTextStyles.bodySmall, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(CurrencyUtils.format(e.value), + style: AppTextStyles.labelLarge), + Text('${pct.toStringAsFixed(1)}%', + style: AppTextStyles.bodySmall), + ], + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart index ad3da2f..824cbb1 100644 --- a/lib/providers/dashboard_provider.dart +++ b/lib/providers/dashboard_provider.dart @@ -1,9 +1,8 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:bms/data/database/app_database.dart'; +import 'package:bms/data/database/daos/reports_dao.dart'; +import 'package:bms/providers/database_provider.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; -import '../data/database/app_database.dart'; -import 'database_provider.dart'; - part 'dashboard_provider.g.dart'; class DashboardStats { @@ -13,6 +12,10 @@ class DashboardStats { required this.totalDebtors, required this.chequesThisWeek, required this.recentInvoices, + required this.weeklyTrend, + required this.paymentMix, + required this.mtdSales, + required this.lastMonthSales, }); final double todaySales; @@ -20,6 +23,17 @@ class DashboardStats { final double totalDebtors; final int chequesThisWeek; final List recentInvoices; + + // Chart data + final List weeklyTrend; + final Map paymentMix; + final double mtdSales; + final double lastMonthSales; + + double get mtdGrowthPct { + if (lastMonthSales == 0) return 0; + return (mtdSales - lastMonthSales) / lastMonthSales * 100; + } } @riverpod @@ -28,34 +42,42 @@ Future dashboardStats(Ref ref) async { final inventoryDao = ref.watch(inventoryDaoProvider); final customersDao = ref.watch(customersDaoProvider); final chequesDao = ref.watch(chequesDaoProvider); + final reportsDao = ref.watch(reportsDaoProvider); final now = DateTime.now(); final todayStart = DateTime(now.year, now.month, now.day); final todayEnd = todayStart.add(const Duration(days: 1)); + final weekStart = todayStart.subtract(const Duration(days: 6)); + final monthStart = DateTime(now.year, now.month, 1); + final lastMonthStart = DateTime(now.year, now.month - 1, 1); - final results = await Future.wait([ + final (todayInvs, lowStock, debtors, cheques, recent, weekly, thisMonth, lastMonth) = + await ( invoicesDao.getByDateRange(todayStart, todayEnd), inventoryDao.watchLowStock().first, customersDao.getDebtors(), chequesDao.getDueWithinDays(7), - invoicesDao.getByDateRange( - now.subtract(const Duration(days: 30)), - todayEnd, - ), - ]); + invoicesDao.getByDateRange(now.subtract(const Duration(days: 30)), todayEnd), + reportsDao.getDailySales(weekStart, todayEnd), + invoicesDao.getByDateRange(monthStart, todayEnd), + invoicesDao.getByDateRange(lastMonthStart, monthStart), + ).wait; - final todayInvoices = results[0] as List; - final lowStock = results[1] as List; - final debtors = results[2] as List; - final cheques = results[3] as List; - final recent = results[4] as List; + final paymentMix = {}; + for (final inv in thisMonth.where((i) => i.status != 'void')) { + paymentMix[inv.paymentType] = (paymentMix[inv.paymentType] ?? 0) + inv.total; + } return DashboardStats( - todaySales: todayInvoices.fold(0, (sum, inv) => sum + inv.total), + todaySales: todayInvs.fold(0, (s, i) => s + i.total), lowStockCount: lowStock.length, - totalDebtors: debtors.fold(0, (sum, c) => sum + c.balance), + totalDebtors: debtors.fold(0, (s, c) => s + c.balance), chequesThisWeek: cheques.length, recentInvoices: recent.take(8).toList(), + weeklyTrend: weekly, + paymentMix: paymentMix, + mtdSales: thisMonth.where((i) => i.status != 'void').fold(0, (s, i) => s + i.total), + lastMonthSales: lastMonth.where((i) => i.status != 'void').fold(0, (s, i) => s + i.total), ); } @@ -63,7 +85,8 @@ Future dashboardStats(Ref ref) async { Future todaySalesTotal(Ref ref) async { final now = DateTime.now(); final start = DateTime(now.year, now.month, now.day); - final invoices = - await ref.watch(invoicesDaoProvider).getByDateRange(start, start.add(const Duration(days: 1))); + final invoices = await ref + .watch(invoicesDaoProvider) + .getByDateRange(start, start.add(const Duration(days: 1))); return invoices.fold(0.0, (sum, inv) => sum + inv.total); } From d2e771c3244b71c2e1ade8259984ffa41fb8e6d4 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 10:25:00 +0530 Subject: [PATCH 03/26] docs: update CHANGELOG for phase 3 and phase 4 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a14a7e7..9c57d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Reports screen with three tabs: P&L, Stock Valuation, and Debtor Aging +- P&L tab - date range picker, revenue/COGS/gross profit/margin summary cards, daily revenue bar chart with horizontal scrolling for wide ranges +- Stock Valuation tab - total stock value card, per-product value list sorted by value with relative progress bar +- Debtor Aging tab - donut chart splitting outstanding balances into 0-30d / 31-60d / 61-90d / 90+ buckets with per-customer aging badge +- Dashboard 7-day revenue bar chart showing last seven days with day-of-week labels +- Dashboard month-to-date sales card with percentage growth vs previous month +- Dashboard payment method donut chart (cash / card / cheque / credit / mixed) for the current month +- `ReportsDao` - plain DAO class providing `getDailySales`, `getStockValuation`, and `getDebtorAging` using Drift typed join queries; no code-gen required +- `DailySales`, `StockValuationRow`, `DebtorAgingRow` data classes with computed properties (grossProfit, agingBucket, daysPastDue) +- CodeQL Advanced workflow scanning GitHub Actions workflows with `security-extended` and `security-and-quality` query suites +- Dependabot configuration for both `pub` and `github-actions` ecosystems with grouped minor/patch updates and co-dependent package groups (drift, riverpod, freezed, go_router) + +### Changed +- Dashboard provider expanded to fetch 7-day trend, payment mix, MTD sales, and last-month sales in a single parallel await using Dart 3 record `.wait` +- Reports screen replaced placeholder card with full three-tab report suite +- `nextGrnNumber()` in `SuppliersDao` replaced full-table scan with a single `MAX()` aggregate query; O(n) -> O(log n) on the unique index + +### Fixed +- Code Quality CI workflow now parses `flutter analyze` output for `error` level issues instead of relying on exit code, which behaved inconsistently between macOS and Linux runners +- `subosito/flutter-action` action pinned to immutable commit hash to satisfy CodeQL unpinned-action finding +- `dart pub audit` removed from CI; command does not exist in Dart 3.12 - Quick Sales - no-invoice cash sale with automatic stock deduction and ledger entry - GRN (Goods Receipt Note) - supplier picker, product line items, qty/cost editing, stock-in on confirm, cost price update, purchase history tab - Petty Cash - daily float card, receipt photo capture (camera and gallery), approval workflow, category chips From eafc01baff8e692497f0ffa83e15029c46e4388c Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 10:25:24 +0530 Subject: [PATCH 04/26] feat: add flutter_local_notifications_windows to FFI plugin list --- windows/flutter/generated_plugins.cmake | 1 + 1 file changed, 1 insertion(+) diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 1833223..284fc51 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -11,6 +11,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + flutter_local_notifications_windows jni ) From 153227744f2c2aafb76d99e6a8dd5b5774f6c559 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:14:21 +0530 Subject: [PATCH 05/26] feat: add nextReturnNumber() to ReturnsDao using MAX() aggregate --- lib/data/database/daos/returns_dao.dart | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/lib/data/database/daos/returns_dao.dart b/lib/data/database/daos/returns_dao.dart index e47ba0e..2bd7297 100644 --- a/lib/data/database/daos/returns_dao.dart +++ b/lib/data/database/daos/returns_dao.dart @@ -9,6 +9,19 @@ part 'returns_dao.g.dart'; class ReturnsDao extends DatabaseAccessor with _$ReturnsDaoMixin { ReturnsDao(super.db); + Future nextReturnNumber() async { + final maxExpr = salesReturns.returnNo.max(); + final row = + await (selectOnly(salesReturns)..addColumns([maxExpr])).getSingle(); + final maxVal = row.read(maxExpr); + int maxNumber = 0; + if (maxVal != null) { + final match = RegExp(r'RET-(\d+)').firstMatch(maxVal); + if (match != null) maxNumber = int.tryParse(match.group(1)!) ?? 0; + } + return 'RET-${(maxNumber + 1).toString().padLeft(5, '0')}'; + } + Future insertReturnWithItems( SalesReturnsCompanion entry, List items, From e591fd70cc739807d060f419e57c39c458495e60 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:14:26 +0530 Subject: [PATCH 06/26] feat: add optional movementType param to adjustStock --- lib/data/repositories/inventory_repository.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/data/repositories/inventory_repository.dart b/lib/data/repositories/inventory_repository.dart index 8a78bab..0aa75aa 100644 --- a/lib/data/repositories/inventory_repository.dart +++ b/lib/data/repositories/inventory_repository.dart @@ -76,6 +76,7 @@ class InventoryRepository { required String userName, String? refId, String? refType, + String? movementType, }) async { final existing = await _inventory.getStock(productId); final currentQty = existing?.qty ?? 0; @@ -94,7 +95,7 @@ class InventoryRepository { await _inventory.recordMovement( StockMovementsCompanion.insert( id: _uuid.v7(), - type: delta >= 0 ? 'in' : 'out', + type: movementType ?? (delta >= 0 ? 'in' : 'out'), productId: productId, qty: delta.abs(), reason: Value(reason), From ef79fd26f8f8a3c9cf2d742fa5639e3152658853 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:14:26 +0530 Subject: [PATCH 07/26] feat: add invoiceReturnsProvider for per-invoice return history --- lib/providers/invoices_provider.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/providers/invoices_provider.dart b/lib/providers/invoices_provider.dart index 0dce1f0..7735fc5 100644 --- a/lib/providers/invoices_provider.dart +++ b/lib/providers/invoices_provider.dart @@ -6,6 +6,14 @@ import '../features/auth/domain/auth_state.dart'; import 'auth_provider.dart'; import 'database_provider.dart'; +// Returns for a specific invoice + +final invoiceReturnsProvider = + FutureProvider.autoDispose.family, String>( + (ref, invoiceId) => + ref.watch(returnsDaoProvider).getForInvoice(invoiceId), +); + // Filter state class InvoiceFilter { From d52b9ebe778b1159fe541ace9f9e40d88ff5d71d Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:14:26 +0530 Subject: [PATCH 08/26] feat: add sales returns UI to invoice detail screen --- .../presentation/invoice_detail_screen.dart | 729 ++++++++++++++++-- 1 file changed, 684 insertions(+), 45 deletions(-) diff --git a/lib/features/invoices/presentation/invoice_detail_screen.dart b/lib/features/invoices/presentation/invoice_detail_screen.dart index fc0a2e6..982f4f2 100644 --- a/lib/features/invoices/presentation/invoice_detail_screen.dart +++ b/lib/features/invoices/presentation/invoice_detail_screen.dart @@ -1,6 +1,9 @@ +import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:printing/printing.dart'; +import 'package:uuid/uuid.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_text_styles.dart'; @@ -8,6 +11,8 @@ import '../../../core/utils/currency_utils.dart'; import '../../../data/database/app_database.dart'; import '../../../features/auth/domain/auth_state.dart'; import '../../../providers/auth_provider.dart'; +import '../../../providers/database_provider.dart'; +import '../../../providers/inventory_provider.dart'; import '../../../providers/invoices_provider.dart'; import 'invoice_pdf.dart'; @@ -22,6 +27,7 @@ class InvoiceDetailScreen extends ConsumerWidget { final authState = ref.watch(currentAuthStateProvider); final role = authState is Authenticated ? authState.user.role : 'cashier'; final cashierName = authState is Authenticated ? authState.user.name : ''; + final canReturn = role == 'admin' || role == 'developer' || role == 'manager'; final isAdmin = role == 'admin' || role == 'developer'; return Scaffold( @@ -47,8 +53,10 @@ class InvoiceDetailScreen extends ConsumerWidget { data: (d) => _DetailBody( detail: d, isAdmin: isAdmin, + canReturn: canReturn, cashierName: cashierName, onVoid: () => _confirmVoid(context, ref, d.invoice), + onProcessReturn: () => _openReturnSheet(context, ref, d), ), ), ); @@ -121,27 +129,52 @@ class InvoiceDetailScreen extends ConsumerWidget { ); } } + + Future _openReturnSheet( + BuildContext context, WidgetRef ref, InvoiceDetail detail) async { + final processed = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => _ProcessReturnSheet(detail: detail), + ); + if (processed == true) { + ref.invalidate(invoiceReturnsProvider(detail.invoice.id)); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Return processed and stock restored')), + ); + } + } + } } -class _DetailBody extends StatelessWidget { +// ── Detail Body ────────────────────────────────────────────────────────────── + +class _DetailBody extends ConsumerWidget { const _DetailBody({ required this.detail, required this.isAdmin, + required this.canReturn, required this.cashierName, required this.onVoid, + required this.onProcessReturn, }); final InvoiceDetail detail; final bool isAdmin; + final bool canReturn; final String cashierName; final VoidCallback onVoid; + final VoidCallback onProcessReturn; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final inv = detail.invoice; final items = detail.items; final customer = detail.customer; final isVoided = inv.status == 'void'; + final returnsAsync = ref.watch(invoiceReturnsProvider(inv.id)); return ListView( padding: const EdgeInsets.all(24), @@ -162,11 +195,14 @@ class _DetailBody extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('VOIDED', style: AppTextStyles.labelLarge.copyWith(color: AppColors.error)), + Text('VOIDED', + style: AppTextStyles.labelLarge + .copyWith(color: AppColors.error)), if (inv.voidReason != null) Text(inv.voidReason!, style: AppTextStyles.bodySmall), if (inv.voidApprovedBy != null) - Text('By: ${inv.voidApprovedBy}', style: AppTextStyles.bodySmall), + Text('By: ${inv.voidApprovedBy}', + style: AppTextStyles.bodySmall), ], ), ), @@ -183,8 +219,7 @@ class _DetailBody extends StatelessWidget { _InfoRow(label: 'Invoice No', value: inv.invoiceNo), _InfoRow( label: 'Date', - value: - '${inv.createdAt.day.toString().padLeft(2, '0')} ' + value: '${inv.createdAt.day.toString().padLeft(2, '0')} ' '${_monthName(inv.createdAt.month)} ${inv.createdAt.year} ' '${inv.createdAt.hour.toString().padLeft(2, '0')}:' '${inv.createdAt.minute.toString().padLeft(2, '0')}'), @@ -219,7 +254,8 @@ class _DetailBody extends StatelessWidget { children: [ TableRow( decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: AppColors.border))), + border: Border( + bottom: BorderSide(color: AppColors.border))), children: [ _TableHeader('Item'), _TableHeader('Qty', align: TextAlign.center), @@ -235,12 +271,13 @@ class _DetailBody extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.productName, style: AppTextStyles.bodyMedium), + Text(item.productName, + style: AppTextStyles.bodyMedium), if (item.discountPercent > 0) Text( '${item.discountPercent.toStringAsFixed(0)}% off', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.error), + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.error), ), ], ), @@ -312,37 +349,59 @@ class _DetailBody extends StatelessWidget { const SizedBox(height: 24), // Actions - Row( + Wrap( + spacing: 12, + runSpacing: 12, children: [ - Expanded( - child: OutlinedButton.icon( - icon: const Icon(Icons.picture_as_pdf_outlined), - label: const Text('Export PDF'), - onPressed: () async { - final doc = await InvoicePdf.build( - invoice: inv, - items: items, - customer: customer, - cashierName: cashierName, - ); - await Printing.layoutPdf(onLayout: (_) => doc.save()); - }, - ), + OutlinedButton.icon( + icon: const Icon(Icons.picture_as_pdf_outlined), + label: const Text('Export PDF'), + onPressed: () async { + final doc = await InvoicePdf.build( + invoice: inv, + items: items, + customer: customer, + cashierName: cashierName, + ); + await Printing.layoutPdf(onLayout: (_) => doc.save()); + }, ), - if (isAdmin && !isVoided) ...[ - const SizedBox(width: 12), - Expanded( - child: ElevatedButton.icon( - style: ElevatedButton.styleFrom(backgroundColor: AppColors.error), - icon: const Icon(Icons.block_outlined, color: Colors.white), - label: const Text('Void Invoice', - style: TextStyle(color: Colors.white)), - onPressed: onVoid, - ), + if (canReturn && !isVoided && items.isNotEmpty) + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.warning), + icon: const Icon(Icons.assignment_return_outlined, + color: Colors.white), + label: const Text('Process Return', + style: TextStyle(color: Colors.white)), + onPressed: onProcessReturn, + ), + if (isAdmin && !isVoided) + ElevatedButton.icon( + style: + ElevatedButton.styleFrom(backgroundColor: AppColors.error), + icon: const Icon(Icons.block_outlined, color: Colors.white), + label: const Text('Void Invoice', + style: TextStyle(color: Colors.white)), + onPressed: onVoid, ), - ], ], ), + + // Return history + returnsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + data: (returns) { + if (returns.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 28), + child: _ReturnHistory(returns: returns), + ); + }, + ), + + const SizedBox(height: 24), ], ); } @@ -357,11 +416,588 @@ class _DetailBody extends StatelessWidget { }; static String _monthName(int m) => const [ - '', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', - 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + '', + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' ][m]; } +// ── Return History ─────────────────────────────────────────────────────────── + +class _ReturnHistory extends StatelessWidget { + const _ReturnHistory({required this.returns}); + final List returns; + + @override + Widget build(BuildContext context) { + return _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.assignment_return_outlined, + color: AppColors.warning, size: 18), + const SizedBox(width: 8), + Text('Returns (${returns.length})', + style: AppTextStyles.titleMedium), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + for (final ret in returns) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ret.returnNo, style: AppTextStyles.labelLarge), + Text( + _typeLabel(ret.type), + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary), + ), + if (ret.reason != null) + Text(ret.reason!, + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary)), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(CurrencyUtils.format(ret.totalAmount), + style: AppTextStyles.labelLarge + .copyWith(color: AppColors.warning)), + Text( + '${ret.createdAt.day.toString().padLeft(2, '0')}/' + '${ret.createdAt.month.toString().padLeft(2, '0')}/' + '${ret.createdAt.year}', + style: AppTextStyles.bodySmall, + ), + ], + ), + ], + ), + ], + ], + ), + ); + } + + static String _typeLabel(String type) => switch (type) { + 'refund' => 'Refund', + 'credit' => 'Credit Note', + 'exchange' => 'Exchange', + _ => type, + }; +} + +// ── Process Return Sheet ───────────────────────────────────────────────────── + +class _ProcessReturnSheet extends ConsumerStatefulWidget { + const _ProcessReturnSheet({required this.detail}); + final InvoiceDetail detail; + + @override + ConsumerState<_ProcessReturnSheet> createState() => + _ProcessReturnSheetState(); +} + +class _ProcessReturnSheetState extends ConsumerState<_ProcessReturnSheet> { + final _form = GlobalKey(); + late final List _qtyControllers; + String _type = 'refund'; + final _reasonCtrl = TextEditingController(); + bool _submitting = false; + + @override + void initState() { + super.initState(); + _qtyControllers = widget.detail.items + .map((_) => TextEditingController(text: '0')) + .toList(); + } + + @override + void dispose() { + for (final c in _qtyControllers) { + c.dispose(); + } + _reasonCtrl.dispose(); + super.dispose(); + } + + double get _totalReturnAmount { + double total = 0; + for (int i = 0; i < widget.detail.items.length; i++) { + final qty = double.tryParse(_qtyControllers[i].text.trim()) ?? 0; + if (qty > 0) total += widget.detail.items[i].unitPrice * qty; + } + return total; + } + + Future _submit() async { + if (!_form.currentState!.validate()) return; + + final returnItems = <({InvoiceItem item, double qty})>[]; + for (int i = 0; i < widget.detail.items.length; i++) { + final qty = double.tryParse(_qtyControllers[i].text.trim()) ?? 0; + if (qty > 0) returnItems.add((item: widget.detail.items[i], qty: qty)); + } + + if (returnItems.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Enter a return quantity for at least one item')), + ); + return; + } + + setState(() => _submitting = true); + + try { + final authState = ref.read(currentAuthStateProvider); + final userId = + authState is Authenticated ? authState.user.id : 'unknown'; + final userName = + authState is Authenticated ? authState.user.name : 'unknown'; + + final returnsDao = ref.read(returnsDaoProvider); + final inventoryRepo = ref.read(inventoryRepositoryProvider); + const uuid = Uuid(); + + final returnId = uuid.v7(); + final returnNo = await returnsDao.nextReturnNumber(); + final totalAmount = returnItems.fold( + 0.0, (s, e) => s + e.item.unitPrice * e.qty); + + final entry = SalesReturnsCompanion( + id: Value(returnId), + invoiceId: Value(widget.detail.invoice.id), + returnNo: Value(returnNo), + type: Value(_type), + totalAmount: Value(totalAmount), + reason: Value( + _reasonCtrl.text.trim().isEmpty ? null : _reasonCtrl.text.trim()), + userId: Value(userId), + ); + + final items = returnItems + .map((e) => ReturnItemsCompanion( + id: Value(uuid.v7()), + returnId: Value(returnId), + productId: Value(e.item.productId), + productName: Value(e.item.productName), + qty: Value(e.qty), + unitPrice: Value(e.item.unitPrice), + subtotal: Value(e.item.unitPrice * e.qty), + )) + .toList(); + + await returnsDao.insertReturnWithItems(entry, items); + + for (final e in returnItems) { + await inventoryRepo.adjustStock( + productId: e.item.productId, + delta: e.qty, + reason: 'Sales return $returnNo', + userId: userId, + userName: userName, + refId: returnId, + refType: 'sales_return', + movementType: 'return_in', + ); + } + + if (mounted) Navigator.pop(context, true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + setState(() => _submitting = false); + } + } + } + + @override + Widget build(BuildContext context) { + final items = widget.detail.items; + + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: DraggableScrollableSheet( + expand: false, + initialChildSize: 0.85, + minChildSize: 0.5, + maxChildSize: 0.95, + builder: (_, scrollCtrl) => Form( + key: _form, + child: Column( + children: [ + // Handle + Container( + margin: const EdgeInsets.only(top: 12, bottom: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: AppColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + + // Header + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Row( + children: [ + const Icon(Icons.assignment_return_outlined, + color: AppColors.warning), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Process Return - ${widget.detail.invoice.invoiceNo}', + style: AppTextStyles.titleMedium, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + + const Divider(height: 1), + + Expanded( + child: ListView( + controller: scrollCtrl, + padding: const EdgeInsets.all(20), + children: [ + // Items section + Text('Select Items to Return', + style: AppTextStyles.labelLarge), + const SizedBox(height: 12), + + for (int i = 0; i < items.length; i++) + _ReturnItemRow( + item: items[i], + controller: _qtyControllers[i], + onChanged: () => setState(() {}), + ), + + const SizedBox(height: 20), + + // Return type + Text('Return Type', style: AppTextStyles.labelLarge), + const SizedBox(height: 8), + _ReturnTypeSelector( + value: _type, + onChanged: (v) => setState(() => _type = v), + ), + + const SizedBox(height: 20), + + // Reason + Text('Reason', style: AppTextStyles.labelLarge), + const SizedBox(height: 8), + TextFormField( + controller: _reasonCtrl, + decoration: const InputDecoration( + hintText: 'Optional reason for return...', + border: OutlineInputBorder(), + ), + maxLines: 2, + ), + + const SizedBox(height: 24), + + // Total + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.warningLight, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Return Total', + style: AppTextStyles.titleMedium), + Text( + CurrencyUtils.format(_totalReturnAmount), + style: AppTextStyles.titleMedium + .copyWith(color: AppColors.warning), + ), + ], + ), + ), + ], + ), + ), + + // Bottom actions + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: + _submitting ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.warning), + onPressed: _submitting ? null : _submit, + child: _submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white), + ) + : const Text('Confirm Return', + style: TextStyle(color: Colors.white)), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// ── Return Item Row ────────────────────────────────────────────────────────── + +class _ReturnItemRow extends StatelessWidget { + const _ReturnItemRow({ + required this.item, + required this.controller, + required this.onChanged, + }); + + final InvoiceItem item; + final TextEditingController controller; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final maxQty = item.qty; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.productName, style: AppTextStyles.bodyMedium), + Text( + 'Sold: ${item.qty % 1 == 0 ? item.qty.toInt() : item.qty.toStringAsFixed(2)} @ ${CurrencyUtils.format(item.unitPrice)}', + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + const SizedBox(width: 12), + _QtyStepper( + controller: controller, + max: maxQty, + onChanged: onChanged, + ), + ], + ), + ); + } +} + +// ── Qty Stepper ────────────────────────────────────────────────────────────── + +class _QtyStepper extends StatelessWidget { + const _QtyStepper({ + required this.controller, + required this.max, + required this.onChanged, + }); + + final TextEditingController controller; + final double max; + final VoidCallback onChanged; + + void _decrement() { + final current = double.tryParse(controller.text) ?? 0; + if (current <= 0) return; + final next = (current - 1).clamp(0.0, max); + controller.text = next == next.toInt() ? next.toInt().toString() : next.toStringAsFixed(2); + onChanged(); + } + + void _increment() { + final current = double.tryParse(controller.text) ?? 0; + if (current >= max) return; + final next = (current + 1).clamp(0.0, max); + controller.text = next == next.toInt() ? next.toInt().toString() : next.toStringAsFixed(2); + onChanged(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: _decrement, + borderRadius: BorderRadius.circular(4), + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.remove, size: 16), + ), + ), + SizedBox( + width: 52, + child: TextFormField( + controller: controller, + textAlign: TextAlign.center, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(horizontal: 4, vertical: 8), + border: OutlineInputBorder(), + ), + onChanged: (_) => onChanged(), + validator: (v) { + final qty = double.tryParse(v ?? ''); + if (qty == null) return '*'; + if (qty < 0) return '*'; + if (qty > max) return '>max'; + return null; + }, + ), + ), + InkWell( + onTap: _increment, + borderRadius: BorderRadius.circular(4), + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + child: const Icon(Icons.add, size: 16), + ), + ), + ], + ); + } +} + +// ── Return Type Selector ───────────────────────────────────────────────────── + +class _ReturnTypeSelector extends StatelessWidget { + const _ReturnTypeSelector({required this.value, required this.onChanged}); + final String value; + final ValueChanged onChanged; + + static const _types = [ + ('refund', 'Refund', Icons.payments_outlined), + ('credit', 'Credit Note', Icons.note_outlined), + ('exchange', 'Exchange', Icons.swap_horiz_outlined), + ]; + + @override + Widget build(BuildContext context) { + return Row( + children: _types.map((t) { + final selected = value == t.$1; + return Expanded( + child: GestureDetector( + onTap: () => onChanged(t.$1), + child: Container( + margin: const EdgeInsets.only(right: 8), + padding: + const EdgeInsets.symmetric(vertical: 10, horizontal: 4), + decoration: BoxDecoration( + color: selected + ? AppColors.warning.withValues(alpha: 0.1) + : Colors.transparent, + border: Border.all( + color: selected ? AppColors.warning : AppColors.border, + width: selected ? 1.5 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Icon(t.$3, + color: selected + ? AppColors.warning + : AppColors.textSecondary, + size: 20), + const SizedBox(height: 4), + Text( + t.$2, + style: AppTextStyles.bodySmall.copyWith( + color: selected + ? AppColors.warning + : AppColors.textSecondary, + fontWeight: selected + ? FontWeight.w600 + : FontWeight.normal, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + }).toList(), + ); + } +} + +// ── Shared UI Widgets ──────────────────────────────────────────────────────── class _Card extends StatelessWidget { const _Card({required this.child}); @@ -393,8 +1029,9 @@ class _InfoRow extends StatelessWidget { children: [ SizedBox( width: 110, - child: - Text(label, style: AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary)), + child: Text(label, + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary)), ), valueWidget ?? Text(value ?? '', style: AppTextStyles.bodyMedium), ], @@ -422,13 +1059,13 @@ class _TotalRow extends StatelessWidget { children: [ Text(label, style: style ?? - AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary)), + AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary)), Text( value < 0 ? '- ${CurrencyUtils.format(-value)}' : CurrencyUtils.format(value), - style: (style ?? AppTextStyles.bodyMedium) - .copyWith(color: color), + style: (style ?? AppTextStyles.bodyMedium).copyWith(color: color), ), ], ), @@ -444,7 +1081,8 @@ class _TableHeader extends StatelessWidget { Widget build(BuildContext context) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Text(text, - style: AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary), + style: + AppTextStyles.bodySmall.copyWith(color: AppColors.textSecondary), textAlign: align), ); } @@ -464,7 +1102,8 @@ class _StatusChip extends StatelessWidget { }; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), child: Text(status.toUpperCase(), style: AppTextStyles.bodySmall.copyWith( color: fg, fontWeight: FontWeight.w700, fontSize: 11)), From d940452585ddd1cbfabaad85ab73410f9a4c7be0 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:14:26 +0530 Subject: [PATCH 09/26] docs: update changelog with sales returns UI --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c57d54..aa67812 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Dependabot configuration for both `pub` and `github-actions` ecosystems with grouped minor/patch updates and co-dependent package groups (drift, riverpod, freezed, go_router) ### Changed +- `InventoryRepository.adjustStock()` accepts optional `movementType` parameter so callers can record `return_in` movements instead of the default `in` +- `InvoiceDetailScreen` action row switched from `Row` to `Wrap` to handle three buttons without overflow on narrow screens - Dashboard provider expanded to fetch 7-day trend, payment mix, MTD sales, and last-month sales in a single parallel await using Dart 3 record `.wait` - Reports screen replaced placeholder card with full three-tab report suite - `nextGrnNumber()` in `SuppliersDao` replaced full-table scan with a single `MAX()` aggregate query; O(n) -> O(log n) on the unique index @@ -39,7 +41,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Banner and logo assets in `docs/` for GitHub org branding - Apache 2.0 license - GitHub PR template and issue templates (bug report, feature request, task) -- Sales returns schema tables scaffolded for the next release +- Sales Returns UI: "Process Return" button on invoice detail (admin/manager only) opens a bottom sheet to select items, quantities, return type (refund/credit/exchange), and reason +- Return history section on invoice detail showing all past returns with type, total, and date +- `nextReturnNumber()` in `ReturnsDao` using `MAX()` aggregate for O(log n) return number generation +- `invoiceReturnsProvider` Riverpod provider to watch returns for a specific invoice - Role-based routing with guards (Admin, Manager, Cashier, Viewer) - Drift (SQLite) database with UUID primary keys and versioned migrations - Collapsible sidebar rail navigation From 41d2ab6e4afb2e74c02e8c68bba0863f14d4bc29 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:51:17 +0530 Subject: [PATCH 10/26] feat: extend dashboard stats to 30-day trend with GP and avg order metrics --- lib/providers/dashboard_provider.dart | 60 +++++++++++++++++++++------ 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart index 824cbb1..da9cd57 100644 --- a/lib/providers/dashboard_provider.dart +++ b/lib/providers/dashboard_provider.dart @@ -12,10 +12,12 @@ class DashboardStats { required this.totalDebtors, required this.chequesThisWeek, required this.recentInvoices, - required this.weeklyTrend, + required this.salesTrend, required this.paymentMix, required this.mtdSales, required this.lastMonthSales, + required this.mtdInvoiceCount, + required this.avgOrderValue, }); final double todaySales; @@ -24,16 +26,32 @@ class DashboardStats { final int chequesThisWeek; final List recentInvoices; - // Chart data - final List weeklyTrend; + // 30-day daily trend (revenue + COGS so grossProfit is available) + final List salesTrend; + final Map paymentMix; final double mtdSales; final double lastMonthSales; + final int mtdInvoiceCount; + final double avgOrderValue; double get mtdGrowthPct { if (lastMonthSales == 0) return 0; return (mtdSales - lastMonthSales) / lastMonthSales * 100; } + + List get last7Days { + if (salesTrend.length <= 7) return salesTrend; + return salesTrend.sublist(salesTrend.length - 7); + } + + double get mtdGrossProfit => + salesTrend.fold(0.0, (s, d) => s + d.grossProfit); + + double get mtdGrossMarginPct { + if (mtdSales == 0) return 0; + return mtdGrossProfit / mtdSales * 100; + } } @riverpod @@ -47,37 +65,51 @@ Future dashboardStats(Ref ref) async { final now = DateTime.now(); final todayStart = DateTime(now.year, now.month, now.day); final todayEnd = todayStart.add(const Duration(days: 1)); - final weekStart = todayStart.subtract(const Duration(days: 6)); + final thirtyDaysAgo = todayStart.subtract(const Duration(days: 29)); final monthStart = DateTime(now.year, now.month, 1); final lastMonthStart = DateTime(now.year, now.month - 1, 1); - final (todayInvs, lowStock, debtors, cheques, recent, weekly, thisMonth, lastMonth) = + final (todayInvs, lowStock, debtors, cheques, recent, trend, thisMonth, lastMonth) = await ( invoicesDao.getByDateRange(todayStart, todayEnd), inventoryDao.watchLowStock().first, customersDao.getDebtors(), chequesDao.getDueWithinDays(7), invoicesDao.getByDateRange(now.subtract(const Duration(days: 30)), todayEnd), - reportsDao.getDailySales(weekStart, todayEnd), + reportsDao.getDailySales(thirtyDaysAgo, todayEnd), invoicesDao.getByDateRange(monthStart, todayEnd), invoicesDao.getByDateRange(lastMonthStart, monthStart), ).wait; final paymentMix = {}; + int mtdCount = 0; + double mtdTotal = 0; for (final inv in thisMonth.where((i) => i.status != 'void')) { - paymentMix[inv.paymentType] = (paymentMix[inv.paymentType] ?? 0) + inv.total; + paymentMix[inv.paymentType] = + (paymentMix[inv.paymentType] ?? 0) + inv.total; + mtdCount++; + mtdTotal += inv.total; } return DashboardStats( - todaySales: todayInvs.fold(0, (s, i) => s + i.total), + todaySales: todayInvs + .where((i) => i.status != 'void') + .fold(0, (s, i) => s + i.total), lowStockCount: lowStock.length, totalDebtors: debtors.fold(0, (s, c) => s + c.balance), chequesThisWeek: cheques.length, - recentInvoices: recent.take(8).toList(), - weeklyTrend: weekly, + recentInvoices: recent + .where((i) => i.status != 'void') + .take(8) + .toList(), + salesTrend: trend, paymentMix: paymentMix, - mtdSales: thisMonth.where((i) => i.status != 'void').fold(0, (s, i) => s + i.total), - lastMonthSales: lastMonth.where((i) => i.status != 'void').fold(0, (s, i) => s + i.total), + mtdSales: mtdTotal, + lastMonthSales: lastMonth + .where((i) => i.status != 'void') + .fold(0, (s, i) => s + i.total), + mtdInvoiceCount: mtdCount, + avgOrderValue: mtdCount > 0 ? mtdTotal / mtdCount : 0, ); } @@ -88,5 +120,7 @@ Future todaySalesTotal(Ref ref) async { final invoices = await ref .watch(invoicesDaoProvider) .getByDateRange(start, start.add(const Duration(days: 1))); - return invoices.fold(0.0, (sum, inv) => sum + inv.total); + return invoices + .where((i) => i.status != 'void') + .fold(0.0, (sum, inv) => sum + inv.total); } From ab299c34996762f6b472df16246ea1f4153df5c2 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:51:17 +0530 Subject: [PATCH 11/26] feat: redesign dashboard with enterprise charts --- .../presentation/dashboard_screen.dart | 964 ++++++++++++++---- 1 file changed, 766 insertions(+), 198 deletions(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 20e036c..dfc966f 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -9,6 +9,7 @@ import 'package:bms/core/theme/app_colors.dart'; import 'package:bms/core/theme/app_text_styles.dart'; import 'package:bms/core/utils/currency_utils.dart'; import 'package:bms/core/utils/date_utils.dart'; +import 'package:bms/data/database/app_database.dart'; import 'package:bms/data/database/daos/reports_dao.dart'; import 'package:bms/providers/dashboard_provider.dart'; import 'package:bms/shared/widgets/stat_card.dart'; @@ -32,7 +33,7 @@ class DashboardScreen extends ConsumerWidget { child: ListView( padding: const EdgeInsets.all(16), children: [ - // ── Stat Cards ────────────────────────────────────────────── + // ── KPI Grid ──────────────────────────────────────────────── GridView.extent( maxCrossAxisExtent: 300, mainAxisSpacing: 12, @@ -74,55 +75,56 @@ class DashboardScreen extends ConsumerWidget { const SizedBox(height: 20), - // ── MTD Sales vs Last Month ────────────────────────────────── - _MtdCard( - mtd: s.mtdSales, - lastMonth: s.lastMonthSales, - growthPct: s.mtdGrowthPct, + // ── MTD Performance ───────────────────────────────────────── + _MtdPerformanceCard(s: s), + + const SizedBox(height: 28), + + // ── 30-Day Revenue Trend ───────────────────────────────────── + _SectionHeader( + title: 'Revenue Trend', + subtitle: 'Last 30 days - Revenue vs Gross Profit', ), + const SizedBox(height: 12), + _RevenueTrendChart(trend: s.salesTrend), - const SizedBox(height: 24), + const SizedBox(height: 28), - // ── 7-Day Revenue Chart ────────────────────────────────────── - Text('7-Day Revenue', style: AppTextStyles.titleMedium), + // ── Weekly Performance ──────────────────────────────────────── + _SectionHeader( + title: 'Weekly Performance', + subtitle: 'Last 7 days', + ), const SizedBox(height: 12), - _WeeklyBarChart(trend: s.weeklyTrend), + _WeeklyGroupedChart(days: s.last7Days), // ── Payment Mix ────────────────────────────────────────────── if (s.paymentMix.isNotEmpty) ...[ - const SizedBox(height: 24), - Text('Payment Mix — This Month', - style: AppTextStyles.titleMedium), + const SizedBox(height: 28), + _SectionHeader( + title: 'Payment Mix', + subtitle: 'Current month by method', + ), const SizedBox(height: 12), - _PaymentMixChart(mix: s.paymentMix), + _PaymentMixCard( + mix: s.paymentMix, + totalSales: s.mtdSales, + ), ], - const SizedBox(height: 24), - // ── Recent Invoices ────────────────────────────────────────── if (s.recentInvoices.isNotEmpty) ...[ - Text('Recent Invoices', style: AppTextStyles.titleMedium), - const SizedBox(height: 12), - ...s.recentInvoices.map( - (inv) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - leading: const Icon(Icons.receipt_outlined, - color: AppColors.primary), - title: Text(inv.invoiceNo, - style: AppTextStyles.labelLarge), - subtitle: Text( - BmsDateUtils.formatDateTime(inv.createdAt), - style: AppTextStyles.bodySmall), - trailing: Text( - CurrencyUtils.format(inv.total), - style: AppTextStyles.titleMedium - .copyWith(color: AppColors.success), - ), - ), - ), + const SizedBox(height: 28), + _SectionHeader( + title: 'Recent Invoices', + subtitle: 'Last 30 days', + onTap: () => context.go(AppRoutes.invoices), ), + const SizedBox(height: 12), + _RecentInvoicesList(invoices: s.recentInvoices), ], + + const SizedBox(height: 24), ], ), ), @@ -131,77 +133,194 @@ class DashboardScreen extends ConsumerWidget { } } -// ── MTD Card ──────────────────────────────────────────────────────────────── +// ── Section Header ─────────────────────────────────────────────────────────── -class _MtdCard extends StatelessWidget { - const _MtdCard({ - required this.mtd, - required this.lastMonth, - required this.growthPct, +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + this.subtitle, + this.onTap, }); - final double mtd; - final double lastMonth; - final double growthPct; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTextStyles.titleMedium), + if (subtitle != null) + Text( + subtitle!, + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary), + ), + ], + ), + ), + if (onTap != null) + TextButton( + onPressed: onTap, + child: const Text('View All'), + ), + ], + ); +} + +// ── MTD Performance Card ───────────────────────────────────────────────────── + +class _MtdPerformanceCard extends StatelessWidget { + const _MtdPerformanceCard({required this.s}); + final DashboardStats s; @override Widget build(BuildContext context) { - final isPositive = growthPct >= 0; - final color = isPositive ? AppColors.success : AppColors.error; - final icon = isPositive ? Icons.trending_up : Icons.trending_down; - - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('Month-to-Date Sales', - style: AppTextStyles.bodySmall), - const SizedBox(height: 4), - Text(CurrencyUtils.format(mtd), - style: AppTextStyles.titleLarge - .copyWith(color: AppColors.primary)), - if (lastMonth > 0) - Text( - 'vs ${CurrencyUtils.format(lastMonth)} last month', - style: AppTextStyles.bodySmall, - ), - ], + final isPositive = s.mtdGrowthPct >= 0; + final growthColor = isPositive ? AppColors.success : AppColors.error; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary, + AppColors.primary.withValues(alpha: 0.8), + ], + ), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.bar_chart_outlined, + color: Colors.white70, size: 16), + const SizedBox(width: 6), + Text( + 'Month-to-Date Performance', + style: AppTextStyles.bodySmall.copyWith(color: Colors.white70), ), - ), - if (lastMonth > 0) - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, + ], + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, color: color, size: 18), - const SizedBox(width: 4), Text( - '${growthPct.abs().toStringAsFixed(1)}%', - style: AppTextStyles.titleMedium.copyWith(color: color), + CurrencyUtils.format(s.mtdSales), + style: AppTextStyles.titleLarge.copyWith( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w700), + ), + const SizedBox(height: 4), + Text( + '${s.mtdInvoiceCount} invoices', + style: AppTextStyles.bodySmall + .copyWith(color: Colors.white60), ), ], ), ), - ], - ), + if (s.lastMonthSales > 0) + Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + Icon( + isPositive + ? Icons.trending_up + : Icons.trending_down, + color: growthColor, + size: 20, + ), + const SizedBox(height: 2), + Text( + '${s.mtdGrowthPct.abs().toStringAsFixed(1)}%', + style: AppTextStyles.titleMedium + .copyWith(color: growthColor), + ), + Text( + 'vs last month', + style: AppTextStyles.bodySmall + .copyWith(color: Colors.white60, fontSize: 10), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider(color: Colors.white24, height: 1), + const SizedBox(height: 12), + Row( + children: [ + _MtdMetric( + label: 'Gross Profit', + value: CurrencyUtils.format(s.mtdGrossProfit), + color: AppColors.success, + ), + const SizedBox(width: 24), + _MtdMetric( + label: 'Margin', + value: '${s.mtdGrossMarginPct.toStringAsFixed(1)}%', + color: Colors.white, + ), + const SizedBox(width: 24), + _MtdMetric( + label: 'Avg Order', + value: CurrencyUtils.format(s.avgOrderValue), + color: Colors.white, + ), + ], + ), + ], ), ); } } -// ── Weekly Bar Chart ───────────────────────────────────────────────────────── +class _MtdMetric extends StatelessWidget { + const _MtdMetric({required this.label, required this.value, required this.color}); + final String label; + final String value; + final Color color; + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: AppTextStyles.bodySmall.copyWith(color: Colors.white60)), + const SizedBox(height: 2), + Text(value, + style: + AppTextStyles.labelLarge.copyWith(color: color, fontSize: 13)), + ], + ); +} + +// ── 30-Day Revenue Trend Line Chart ────────────────────────────────────────── -class _WeeklyBarChart extends StatelessWidget { - const _WeeklyBarChart({required this.trend}); +class _RevenueTrendChart extends StatelessWidget { + const _RevenueTrendChart({required this.trend}); final List trend; static String _compact(double v) { @@ -212,96 +331,344 @@ class _WeeklyBarChart extends StatelessWidget { @override Widget build(BuildContext context) { - if (trend.isEmpty) { + final hasSales = trend.any((d) => d.revenue > 0); + + if (!hasSales) { + return _ChartCard( + height: 240, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.show_chart_outlined, + size: 40, color: AppColors.border), + const SizedBox(height: 8), + Text('No sales data for the last 30 days.', + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary)), + ], + ), + ), + ); + } + + final maxY = trend.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); + + final revenueSpots = trend.asMap().entries + .map((e) => FlSpot(e.key.toDouble(), e.value.revenue)) + .toList(); + final gpSpots = trend.asMap().entries + .map((e) => FlSpot(e.key.toDouble(), e.value.grossProfit.clamp(0, double.infinity))) + .toList(); + + final dateFmt = DateFormat('d MMM'); + + return _ChartCard( + height: 280, + child: Column( + children: [ + // Legend + Row( + children: [ + _ChartLegendDot(color: AppColors.primary, label: 'Revenue'), + const SizedBox(width: 16), + _ChartLegendDot(color: AppColors.success, label: 'Gross Profit'), + ], + ), + const SizedBox(height: 16), + Expanded( + child: LineChart( + LineChartData( + minY: 0, + maxY: maxY > 0 ? maxY * 1.25 : 100, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: maxY > 0 ? maxY / 4 : 25, + getDrawingHorizontalLine: (_) => FlLine( + color: AppColors.border.withValues(alpha: 0.6), + strokeWidth: 0.8, + ), + ), + borderData: FlBorderData(show: false), + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) => spots.map((spot) { + final day = trend[spot.x.toInt()]; + final isRevenue = spot.barIndex == 0; + return LineTooltipItem( + '${isRevenue ? "Revenue" : "GP"}\n${CurrencyUtils.format(spot.y)}', + AppTextStyles.bodySmall.copyWith( + color: isRevenue ? AppColors.primary : AppColors.success, + fontWeight: FontWeight.w600, + ), + children: isRevenue + ? [ + TextSpan( + text: '\n${dateFmt.format(day.date)}', + style: AppTextStyles.bodySmall + .copyWith(color: Colors.white70), + ), + ] + : null, + ); + }).toList(), + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + interval: maxY > 0 ? maxY / 4 : 25, + getTitlesWidget: (v, meta) { + if (v == meta.max || v == 0) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text(_compact(v), + style: AppTextStyles.bodySmall), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: 7, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || idx >= trend.length) { + return const SizedBox.shrink(); + } + if (idx % 7 != 0 && idx != trend.length - 1) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + dateFmt.format(trend[idx].date), + style: AppTextStyles.bodySmall, + ), + ); + }, + ), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: revenueSpots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.primary, + barWidth: 2.5, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.primary.withValues(alpha: 0.18), + AppColors.primary.withValues(alpha: 0.0), + ], + ), + ), + ), + LineChartBarData( + spots: gpSpots, + isCurved: true, + curveSmoothness: 0.3, + color: AppColors.success, + barWidth: 2, + dashArray: [4, 3], + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.success.withValues(alpha: 0.10), + AppColors.success.withValues(alpha: 0.0), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// ── Weekly Grouped Bar Chart ───────────────────────────────────────────────── + +class _WeeklyGroupedChart extends StatelessWidget { + const _WeeklyGroupedChart({required this.days}); + final List days; + + static String _compact(double v) { + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}K'; + return v.toStringAsFixed(0); + } + + @override + Widget build(BuildContext context) { + if (days.isEmpty) { return const SizedBox( height: 160, child: Center( - child: Text('No sales in the last 7 days.', - style: AppTextStyles.bodySmall), + child: Text('No data.', style: AppTextStyles.bodySmall), ), ); } - final maxY = trend.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); + final maxY = days.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); final fmt = DateFormat('EEE'); - final groups = trend.asMap().entries.map((e) { + final groups = days.asMap().entries.map((e) { final d = e.value; + final gp = d.grossProfit.clamp(0.0, double.infinity); return BarChartGroupData( x: e.key, + barsSpace: 3, barRods: [ BarChartRodData( toY: d.revenue, - width: 28, + width: 12, color: d.revenue > 0 ? AppColors.primary : AppColors.border, - borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), + borderRadius: + const BorderRadius.vertical(top: Radius.circular(4)), + ), + BarChartRodData( + toY: gp, + width: 12, + color: gp > 0 ? AppColors.success : AppColors.border, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(4)), ), ], ); }).toList(); - return SizedBox( - height: 200, - child: BarChart( - BarChartData( - maxY: maxY > 0 ? maxY * 1.2 : 100, - barGroups: groups, - gridData: const FlGridData(show: true, drawVerticalLine: false), - borderData: FlBorderData(show: false), - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (_, __, rod, ___) => BarTooltipItem( - CurrencyUtils.format(rod.toY), - AppTextStyles.bodySmall.copyWith(color: Colors.white), - ), - ), + return _ChartCard( + height: 240, + child: Column( + children: [ + Row( + children: [ + _ChartLegendDot(color: AppColors.primary, label: 'Revenue'), + const SizedBox(width: 16), + _ChartLegendDot(color: AppColors.success, label: 'Gross Profit'), + ], ), - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 52, - getTitlesWidget: (v, meta) => SideTitleWidget( - axisSide: meta.axisSide, - child: Text(_compact(v), style: AppTextStyles.bodySmall), + const SizedBox(height: 12), + Expanded( + child: BarChart( + BarChartData( + maxY: maxY > 0 ? maxY * 1.25 : 100, + groupsSpace: 16, + barGroups: groups, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => FlLine( + color: AppColors.border.withValues(alpha: 0.6), + strokeWidth: 0.8, + ), ), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (v, meta) { - final idx = v.toInt(); - if (idx < 0 || idx >= trend.length) { - return const SizedBox.shrink(); - } - return SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - fmt.format(trend[idx].date), - style: AppTextStyles.bodySmall, + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, _, rod, rodIndex) { + final day = days[group.x]; + final label = rodIndex == 0 ? 'Revenue' : 'Gross Profit'; + return BarTooltipItem( + '$label\n${CurrencyUtils.format(rod.toY)}', + AppTextStyles.bodySmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + children: rodIndex == 0 + ? [ + TextSpan( + text: '\n${fmt.format(day.date)}', + style: AppTextStyles.bodySmall + .copyWith(color: Colors.white70), + ), + ] + : null, + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (v, meta) { + if (v == meta.max || v == 0) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text(_compact(v), + style: AppTextStyles.bodySmall), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || idx >= days.length) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + fmt.format(days[idx].date), + style: AppTextStyles.bodySmall, + ), + ); + }, ), - ); - }, + ), + ), ), ), ), - ), + ], ), ); } } -// ── Payment Mix Donut ──────────────────────────────────────────────────────── +// ── Payment Mix Card ───────────────────────────────────────────────────────── -class _PaymentMixChart extends StatelessWidget { - const _PaymentMixChart({required this.mix}); +class _PaymentMixCard extends StatelessWidget { + const _PaymentMixCard({required this.mix, required this.totalSales}); final Map mix; + final double totalSales; static const _colorMap = { 'cash': AppColors.success, @@ -320,74 +687,275 @@ class _PaymentMixChart extends StatelessWidget { final entries = mix.entries.toList() ..sort((a, b) => b.value.compareTo(a.value)); - return Row( - children: [ - SizedBox( - width: 160, - height: 160, - child: PieChart( - PieChartData( - sections: entries.map((e) { + return _ChartCard( + child: Row( + children: [ + // Donut chart with center label + SizedBox( + width: 160, + height: 180, + child: Stack( + alignment: Alignment.center, + children: [ + PieChart( + PieChartData( + sections: entries.map((e) { + final pct = total > 0 ? e.value / total * 100 : 0.0; + return PieChartSectionData( + value: e.value, + color: _colorFor(e.key), + radius: 54, + title: pct >= 10 + ? '${pct.toStringAsFixed(0)}%' + : '', + titleStyle: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + showTitle: pct >= 10, + ); + }).toList(), + centerSpaceRadius: 38, + sectionsSpace: 2, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Total', + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary, fontSize: 10), + ), + Text( + _compactAmount(total), + style: AppTextStyles.labelLarge.copyWith( + color: AppColors.primary, + fontSize: 13, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(width: 16), + // Legend table + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map((e) { final pct = total > 0 ? e.value / total * 100 : 0.0; - return PieChartSectionData( - value: e.value, - color: _colorFor(e.key), - radius: 52, - title: '${pct.toStringAsFixed(0)}%', - titleStyle: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Colors.white, + final color = _colorFor(e.key); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + _methodLabel(e.key), + style: AppTextStyles.bodySmall, + ), + ), + Text( + '${pct.toStringAsFixed(1)}%', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + const SizedBox(height: 3), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(3), + child: LinearProgressIndicator( + value: total > 0 ? e.value / total : 0, + minHeight: 4, + backgroundColor: AppColors.border, + valueColor: + AlwaysStoppedAnimation(color), + ), + ), + ), + const SizedBox(width: 8), + Text( + CurrencyUtils.format(e.value), + style: AppTextStyles.bodySmall.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], ), - showTitle: pct >= 8, ); }).toList(), - centerSpaceRadius: 32, - sectionsSpace: 2, ), ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: entries.map((e) { - final pct = total > 0 ? e.value / total * 100 : 0.0; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( + ], + ), + ); + } + + static String _compactAmount(double v) { + if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)}M'; + if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}K'; + return v.toStringAsFixed(0); + } + + static String _methodLabel(String type) => switch (type.toLowerCase()) { + 'cash' => 'Cash', + 'card' => 'Card', + 'cheque' => 'Cheque', + 'credit' => 'Credit', + 'mixed' => 'Mixed', + _ => type, + }; +} + +// ── Recent Invoices List ───────────────────────────────────────────────────── + +class _RecentInvoicesList extends StatelessWidget { + const _RecentInvoicesList({required this.invoices}); + final List invoices; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: Column( + children: invoices.asMap().entries.map((e) { + final inv = e.value; + final isLast = e.key == invoices.length - 1; + return Column( + children: [ + ListTile( + dense: true, + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: _statusColor(inv.status).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.receipt_outlined, + size: 18, + color: _statusColor(inv.status), + ), + ), + title: Text(inv.invoiceNo, style: AppTextStyles.labelLarge), + subtitle: Text( + BmsDateUtils.formatDateTime(inv.createdAt), + style: AppTextStyles.bodySmall, + ), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, children: [ + Text( + CurrencyUtils.format(inv.total), + style: AppTextStyles.labelLarge + .copyWith(color: AppColors.primary), + ), Container( - width: 10, - height: 10, + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 1), decoration: BoxDecoration( - color: _colorFor(e.key), - borderRadius: BorderRadius.circular(2), + color: + _statusColor(inv.status).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), ), - ), - const SizedBox(width: 8), - Expanded( child: Text( - e.key[0].toUpperCase() + e.key.substring(1), - style: AppTextStyles.bodySmall, + inv.status.toUpperCase(), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: _statusColor(inv.status), + ), ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text(CurrencyUtils.format(e.value), - style: AppTextStyles.labelLarge), - Text('${pct.toStringAsFixed(1)}%', - style: AppTextStyles.bodySmall), - ], - ), ], ), - ); - }).toList(), - ), - ), - ], + ), + if (!isLast) + const Divider(height: 1, indent: 52, endIndent: 16), + ], + ); + }).toList(), + ), ); } + + static Color _statusColor(String status) => switch (status) { + 'paid' => AppColors.success, + 'partial' => AppColors.warning, + 'void' => AppColors.error, + 'open' => AppColors.info, + _ => AppColors.textSecondary, + }; +} + +// ── Shared Helpers ──────────────────────────────────────────────────────────── + +class _ChartCard extends StatelessWidget { + const _ChartCard({required this.child, this.height}); + final Widget child; + final double? height; + + @override + Widget build(BuildContext context) => Container( + height: height, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.border), + ), + child: child, + ); +} + +class _ChartLegendDot extends StatelessWidget { + const _ChartLegendDot({required this.color, required this.label}); + final Color color; + final String label; + + @override + Widget build(BuildContext context) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 6), + Text(label, style: AppTextStyles.bodySmall), + ], + ); } From a5c7348730ca7c61b565ad50d9f0a51b43117780 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 17:53:19 +0530 Subject: [PATCH 12/26] fix: improve gross profit contrast on MTD card blue background --- lib/features/dashboard/presentation/dashboard_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index dfc966f..8eeec57 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -275,7 +275,7 @@ class _MtdPerformanceCard extends StatelessWidget { _MtdMetric( label: 'Gross Profit', value: CurrencyUtils.format(s.mtdGrossProfit), - color: AppColors.success, + color: const Color(0xFF69F0AE), ), const SizedBox(width: 24), _MtdMetric( From b1831f667fbc18b3623c24178539e6c1f9a396c5 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:34:58 +0530 Subject: [PATCH 13/26] feat: add proper empty state views to reports screen --- .../reports/presentation/reports_screen.dart | 89 ++++++++++++++----- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/lib/features/reports/presentation/reports_screen.dart b/lib/features/reports/presentation/reports_screen.dart index de85b13..50cabf5 100644 --- a/lib/features/reports/presentation/reports_screen.dart +++ b/lib/features/reports/presentation/reports_screen.dart @@ -103,6 +103,23 @@ class _PLTabState extends ConsumerState<_PLTab> { error: (e, _) => Center(child: Text('Error: $e')), data: (daily) { final revenue = daily.fold(0, (s, d) => s + d.revenue); + + if (revenue == 0) { + return Column( + children: [ + Expanded( + child: _EmptyState( + icon: Icons.bar_chart_outlined, + iconColor: AppColors.primary, + title: 'No Sales Data', + subtitle: + 'No transactions were recorded for this period.\nTry adjusting the date range.', + ), + ), + ], + ); + } + final cogs = daily.fold(0, (s, d) => s + d.cogs); final grossProfit = revenue - cogs; final margin = revenue > 0 ? grossProfit / revenue * 100 : 0.0; @@ -139,15 +156,9 @@ class _PLTabState extends ConsumerState<_PLTab> { ], ), const SizedBox(height: 24), - if (daily.any((d) => d.revenue > 0)) ...[ - Text('Daily Revenue', style: AppTextStyles.titleMedium), - const SizedBox(height: 12), - _PLChart(daily: daily), - ] else - _EmptyState( - icon: Icons.bar_chart_outlined, - message: 'No sales data for this period.', - ), + Text('Daily Revenue', style: AppTextStyles.titleMedium), + const SizedBox(height: 12), + _PLChart(daily: daily), ], ); }, @@ -284,7 +295,10 @@ class _StockTab extends ConsumerWidget { if (rows.isEmpty) { return _EmptyState( icon: Icons.inventory_2_outlined, - message: 'No stock on hand.', + iconColor: AppColors.primary, + title: 'No Stock on Hand', + subtitle: + 'Products with stock will appear here once\nyou record a goods received note.', ); } @@ -462,8 +476,11 @@ class _AgingTab extends ConsumerWidget { data: (rows) { if (rows.isEmpty) { return _EmptyState( - icon: Icons.people_outline, - message: 'No outstanding balances.', + icon: Icons.check_circle_outline, + iconColor: AppColors.success, + title: 'All Clear', + subtitle: + 'No customers have outstanding balances.\nAll receivables are settled.', ); } @@ -713,20 +730,50 @@ class _SummaryGrid extends StatelessWidget { } class _EmptyState extends StatelessWidget { - const _EmptyState({required this.icon, required this.message}); + const _EmptyState({ + required this.icon, + required this.title, + required this.subtitle, + this.iconColor = AppColors.textSecondary, + }); + final IconData icon; - final String message; + final Color iconColor; + final String title; + final String subtitle; @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: AppColors.textSecondary), - const SizedBox(height: 12), - Text(message, style: AppTextStyles.bodySmall), - ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 40, color: iconColor), + ), + const SizedBox(height: 20), + Text( + title, + style: AppTextStyles.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + subtitle, + style: AppTextStyles.bodySmall + .copyWith(color: AppColors.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), ), ); } From edaa21f5c2176fe551cb584bac6e540cf20a9ae0 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:38:11 +0530 Subject: [PATCH 14/26] fix: reduce input field label and hint font size to 13sp globally --- lib/core/theme/app_theme.dart | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart index 0066dfe..2cda156 100644 --- a/lib/core/theme/app_theme.dart +++ b/lib/core/theme/app_theme.dart @@ -72,7 +72,31 @@ abstract final class AppTheme { borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.error), ), - hintStyle: AppTextStyles.bodyMedium.copyWith(color: AppColors.textDisabled), + // Resting label inside the field (13sp keeps it proportional to dense inputs) + labelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w400, + color: AppColors.textSecondary, + ), + // Floating label above the field when focused/filled + floatingLabelStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColors.borderFocus, + ), + hintStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 13, + fontWeight: FontWeight.w400, + color: AppColors.textDisabled, + ), + errorStyle: const TextStyle( + fontFamily: 'Inter', + fontSize: 11, + color: AppColors.error, + ), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( From 50824b9553d58caeeb72ad98da2c6192ea7d5a38 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:41:00 +0530 Subject: [PATCH 15/26] fix: replace aspect-ratio grid with content-driven rows in P&L summary --- .../reports/presentation/reports_screen.dart | 91 ++++++++++++------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/lib/features/reports/presentation/reports_screen.dart b/lib/features/reports/presentation/reports_screen.dart index 50cabf5..713da0b 100644 --- a/lib/features/reports/presentation/reports_screen.dart +++ b/lib/features/reports/presentation/reports_screen.dart @@ -690,41 +690,64 @@ class _SummaryGrid extends StatelessWidget { @override Widget build(BuildContext context) { - return GridView.count( - crossAxisCount: 2, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.6, - children: items.map((item) { - return Card( - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(item.icon, color: item.color, size: 16), - const SizedBox(width: 4), - Text(item.label, - style: AppTextStyles.bodySmall), - ], - ), - const Spacer(), - Text( - item.value, - style: AppTextStyles.titleMedium - .copyWith(color: item.color), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + // Build rows of 2 cards — content-driven height, no aspect ratio distortion + final rows = []; + for (int i = 0; i < items.length; i += 2) { + rows.add(Row( + children: [ + Expanded(child: _SummaryCard(item: items[i])), + const SizedBox(width: 12), + if (i + 1 < items.length) + Expanded(child: _SummaryCard(item: items[i + 1])) + else + const Expanded(child: SizedBox()), + ], + )); + if (i + 2 < items.length) rows.add(const SizedBox(height: 12)); + } + return Column(children: rows); + } +} + +class _SummaryCard extends StatelessWidget { + const _SummaryCard({required this.item}); + final ({String label, String value, Color color, IconData icon}) item; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(item.icon, color: item.color, size: 18), ), - ), - ); - }).toList(), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: AppTextStyles.bodySmall), + const SizedBox(height: 2), + Text( + item.value, + style: AppTextStyles.titleMedium.copyWith(color: item.color), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ), ); } } From 6be40519ab1118281c83b8f833579d35f1f7d9cc Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:44:56 +0530 Subject: [PATCH 16/26] feat: support fractional qty in POS for weight/volume unit types --- lib/features/pos/presentation/pos_screen.dart | 276 ++++++++++++++---- lib/providers/pos_provider.dart | 6 +- 2 files changed, 220 insertions(+), 62 deletions(-) diff --git a/lib/features/pos/presentation/pos_screen.dart b/lib/features/pos/presentation/pos_screen.dart index bd4507d..122b2fd 100644 --- a/lib/features/pos/presentation/pos_screen.dart +++ b/lib/features/pos/presentation/pos_screen.dart @@ -183,12 +183,68 @@ class _ProductCard extends ConsumerWidget { final Product product; final double stockQty; + void _add(BuildContext context, WidgetRef ref) { + if (_isDecimalUnit(product.unitType)) { + _showQtyDialog(context, ref); + } else { + ref.read(posProvider.notifier).addItem(product); + } + } + + void _showQtyDialog(BuildContext context, WidgetRef ref) { + final ctrl = TextEditingController(); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(product.name), + content: TextField( + controller: ctrl, + autofocus: true, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), + ], + decoration: InputDecoration( + labelText: 'Quantity', + suffixText: product.unitType.toUpperCase(), + hintText: '0.5', + ), + onSubmitted: (_) { + final qty = double.tryParse(ctrl.text.trim()) ?? 0; + if (qty > 0) { + ref.read(posProvider.notifier).addItem(product, qty: qty); + } + Navigator.pop(ctx); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final qty = double.tryParse(ctrl.text.trim()) ?? 0; + if (qty > 0) { + ref.read(posProvider.notifier).addItem(product, qty: qty); + } + Navigator.pop(ctx); + }, + child: const Text('Add'), + ), + ], + ), + ); + } + @override Widget build(BuildContext context, WidgetRef ref) { final outOfStock = stockQty <= 0; + final decimal = _isDecimalUnit(product.unitType); return InkWell( - onTap: outOfStock ? null : () => ref.read(posProvider.notifier).addItem(product), + onTap: outOfStock ? null : () => _add(context, ref), borderRadius: BorderRadius.circular(12), child: Ink( decoration: BoxDecoration( @@ -225,13 +281,26 @@ class _ProductCard extends ConsumerWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - Text( - CurrencyUtils.format(product.sellPrice), - style: AppTextStyles.bodyMedium.copyWith(color: AppColors.primary), + Row( + children: [ + Text( + CurrencyUtils.format(product.sellPrice), + style: AppTextStyles.bodyMedium.copyWith(color: AppColors.primary), + ), + if (decimal) ...[ + const SizedBox(width: 4), + Text( + '/${product.unitType}', + style: AppTextStyles.bodySmall, + ), + ], + ], ), const SizedBox(height: 2), Text( - outOfStock ? 'Out of stock' : 'Qty: ${stockQty.toStringAsFixed(0)}', + outOfStock + ? 'Out of stock' + : 'Stock: ${_formatQty(stockQty)} ${product.unitType}', style: AppTextStyles.bodySmall.copyWith( color: outOfStock ? AppColors.error : AppColors.textSecondary, ), @@ -260,6 +329,48 @@ class _CartPanelState extends ConsumerState<_CartPanel> { super.dispose(); } + void _editQty(CartItem item, PosNotifier notifier) { + final ctrl = TextEditingController(text: _formatQty(item.qty)); + final decimal = _isDecimalUnit(item.product.unitType); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(item.product.name), + content: TextField( + controller: ctrl, + autofocus: true, + keyboardType: TextInputType.numberWithOptions(decimal: decimal), + inputFormatters: decimal + ? [FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*'))] + : [FilteringTextInputFormatter.digitsOnly], + decoration: InputDecoration( + labelText: 'Quantity', + suffixText: item.product.unitType.toUpperCase(), + ), + onSubmitted: (_) { + final qty = double.tryParse(ctrl.text.trim()) ?? 0; + if (qty > 0) notifier.updateQty(item.product.id, qty); + Navigator.pop(ctx); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final qty = double.tryParse(ctrl.text.trim()) ?? 0; + if (qty > 0) notifier.updateQty(item.product.id, qty); + Navigator.pop(ctx); + }, + child: const Text('Set'), + ), + ], + ), + ); + } + void _openCustomerSearch() { showDialog( context: context, @@ -455,67 +566,91 @@ class _CartPanelState extends ConsumerState<_CartPanel> { borderRadius: BorderRadius.circular(8), onLongPress: () => _showLineDiscountSheet(item), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.product.name, style: AppTextStyles.labelLarge), + if (item.discountPct > 0) + Text( + '${item.discountPct.toStringAsFixed(0)}% off ${CurrencyUtils.format(item.unitPrice)}', + style: AppTextStyles.bodySmall.copyWith(color: AppColors.warning), + ) + else + Text( + '${CurrencyUtils.format(item.unitPrice)} / ${item.product.unitType}', + style: AppTextStyles.bodySmall, + ), + ], + ), + ), + // Qty stepper with step awareness + tap-to-edit + Row( + mainAxisSize: MainAxisSize.min, children: [ - Text(item.product.name, style: AppTextStyles.labelLarge), - if (item.discountPct > 0) - Text( - '${item.discountPct.toStringAsFixed(0)}% off ${CurrencyUtils.format(item.unitPrice)}', - style: AppTextStyles.bodySmall.copyWith(color: AppColors.warning), - ) - else - Text(CurrencyUtils.format(item.unitPrice), style: AppTextStyles.bodySmall), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + final step = _stepFor(item.product.unitType); + final next = double.parse( + (item.qty - step).toStringAsFixed(4)); + notifier.updateQty(item.product.id, next); + }, + ), + GestureDetector( + onTap: () => _editQty(item, notifier), + child: Container( + constraints: const BoxConstraints(minWidth: 44), + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: AppColors.border), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + _formatQty(item.qty), + textAlign: TextAlign.center, + style: AppTextStyles.labelLarge, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + final step = _stepFor(item.product.unitType); + final next = double.parse( + (item.qty + step).toStringAsFixed(4)); + notifier.updateQty(item.product.id, next); + }, + ), ], ), - ), - Row( - children: [ - IconButton( - icon: const Icon(Icons.remove_circle_outline, size: 20), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => notifier.updateQty(item.product.id, item.qty - 1), - ), - SizedBox( - width: 32, - child: Text( - item.qty.toStringAsFixed(item.qty % 1 == 0 ? 0 : 1), - textAlign: TextAlign.center, - style: AppTextStyles.labelLarge, - ), + const SizedBox(width: 6), + SizedBox( + width: 72, + child: Text( + CurrencyUtils.format(item.lineTotal), + textAlign: TextAlign.end, + style: AppTextStyles.labelLarge, ), - IconButton( - icon: const Icon(Icons.add_circle_outline, size: 20), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => notifier.updateQty(item.product.id, item.qty + 1), - ), - ], - ), - const SizedBox(width: 8), - SizedBox( - width: 72, - child: Text( - CurrencyUtils.format(item.lineTotal), - textAlign: TextAlign.end, - style: AppTextStyles.labelLarge, ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18, color: AppColors.error), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => notifier.removeItem(item.product.id), - ), - ], + IconButton( + icon: const Icon(Icons.close, size: 18, color: AppColors.error), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => notifier.removeItem(item.product.id), + ), + ], + ), ), ), - ), ); }, ), @@ -785,6 +920,29 @@ class _CustomerSearchDialogState extends ConsumerState<_CustomerSearchDialog> { } +// ── Unit-type helpers (shared across cart and product card) ────────────────── + +bool _isDecimalUnit(String unitType) => + const {'kg', 'g', 'l', 'ml'}.contains(unitType.toLowerCase()); + +double _stepFor(String unitType) { + switch (unitType.toLowerCase()) { + case 'kg': + case 'l': + return 0.25; + case 'g': + case 'ml': + return 50; + default: + return 1; + } +} + +String _formatQty(double qty) { + // Trim trailing zeros: 1.500 → "1.5", 1.000 → "1", 0.250 → "0.25" + return qty.toStringAsFixed(3).replaceAll(RegExp(r'\.?0+$'), ''); +} + class _ScanButton extends ConsumerWidget { const _ScanButton({required this.onProductFound}); diff --git a/lib/providers/pos_provider.dart b/lib/providers/pos_provider.dart index ebd279e..abaa9a8 100644 --- a/lib/providers/pos_provider.dart +++ b/lib/providers/pos_provider.dart @@ -85,13 +85,13 @@ class PosNotifier extends _$PosNotifier { @override PosState build() => const PosState(); - void addItem(Product product) { + void addItem(Product product, {double qty = 1}) { final items = List.from(state.items); final idx = items.indexWhere((i) => i.product.id == product.id); if (idx >= 0) { - items[idx] = items[idx].copyWith(qty: items[idx].qty + 1); + items[idx] = items[idx].copyWith(qty: items[idx].qty + qty); } else { - items.add(CartItem(product: product, qty: 1, unitPrice: product.sellPrice)); + items.add(CartItem(product: product, qty: qty, unitPrice: product.sellPrice)); } state = state.copyWith(items: items); } From d25492f713d309d9e0c5fdbf30def08cc6d4f22e Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:46:56 +0530 Subject: [PATCH 17/26] fix: make P&L bar chart fill available width instead of fixed 440px --- .../reports/presentation/reports_screen.dart | 145 +++++++++--------- 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/lib/features/reports/presentation/reports_screen.dart b/lib/features/reports/presentation/reports_screen.dart index 713da0b..58bf545 100644 --- a/lib/features/reports/presentation/reports_screen.dart +++ b/lib/features/reports/presentation/reports_screen.dart @@ -182,94 +182,93 @@ class _PLChart extends StatelessWidget { @override Widget build(BuildContext context) { final maxY = daily.map((d) => d.revenue).fold(0, (a, b) => a > b ? a : b); - final barWidth = daily.length <= 14 ? 14.0 : 8.0; + + // Show a label every N days depending on range length + final labelStep = daily.length <= 10 + ? 1 + : daily.length <= 20 + ? 2 + : 5; final barGroups = daily.asMap().entries.map((e) { - final idx = e.key; final d = e.value; return BarChartGroupData( - x: idx, + x: e.key, barRods: [ BarChartRodData( toY: d.revenue, - width: barWidth, - color: AppColors.primary, - borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), + color: d.revenue > 0 ? AppColors.primary : AppColors.border, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(3)), ), ], ); }).toList(); - // Show a bottom label every N days to avoid crowding - final labelStep = daily.length <= 10 - ? 1 - : daily.length <= 20 - ? 2 - : 5; - return SizedBox( - height: 220, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: SizedBox( - width: (barWidth + 4) * daily.length + 80, - child: BarChart( - BarChartData( - maxY: maxY * 1.15, - barGroups: barGroups, - gridData: const FlGridData( - show: true, - drawVerticalLine: false, - ), - borderData: FlBorderData(show: false), - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - getTooltipItem: (_, _, rod, _) => BarTooltipItem( - CurrencyUtils.format(rod.toY), - AppTextStyles.bodySmall.copyWith(color: Colors.white), - ), - ), + height: 240, + child: BarChart( + BarChartData( + maxY: maxY * 1.2, + barGroups: barGroups, + gridData: FlGridData( + show: true, + drawVerticalLine: false, + getDrawingHorizontalLine: (_) => FlLine( + color: AppColors.border.withValues(alpha: 0.6), + strokeWidth: 0.8, + dashArray: [4, 4], + ), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, _, rod, _) { + final d = daily[group.x]; + return BarTooltipItem( + '${d.date.day}/${d.date.month}\n${CurrencyUtils.format(rod.toY)}', + AppTextStyles.bodySmall.copyWith( + color: Colors.white, fontWeight: FontWeight.w600), + ); + }, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 52, + getTitlesWidget: (v, meta) { + if (v == 0 || v == meta.max) return const SizedBox.shrink(); + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text(_compact(v), style: AppTextStyles.bodySmall), + ); + }, ), - titlesData: FlTitlesData( - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false)), - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 52, - getTitlesWidget: (v, meta) => SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - _compact(v), - style: AppTextStyles.bodySmall, - ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || idx >= daily.length || idx % labelStep != 0) { + return const SizedBox.shrink(); + } + final d = daily[idx].date; + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + '${d.day}/${d.month}', + style: AppTextStyles.bodySmall, ), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 28, - getTitlesWidget: (v, meta) { - final idx = v.toInt(); - if (idx < 0 || - idx >= daily.length || - idx % labelStep != 0) { - return const SizedBox.shrink(); - } - final d = daily[idx].date; - return SideTitleWidget( - axisSide: meta.axisSide, - child: Text( - '${d.day}/${d.month}', - style: AppTextStyles.bodySmall, - ), - ); - }, - ), - ), + ); + }, ), ), ), From 1b3c56a8b1119a8863aa2ed5fbd4c8566c67b362 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:52:04 +0530 Subject: [PATCH 18/26] fix: clean up dashboard appbar title and tooltip behavior --- .../presentation/dashboard_screen.dart | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 8eeec57..1f72756 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -23,7 +23,21 @@ class DashboardScreen extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Text('Dashboard — ${BmsDateUtils.formatDate(DateTime.now())}'), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Dashboard'), + Text( + BmsDateUtils.formatDate(DateTime.now()), + style: const TextStyle( + fontFamily: 'Inter', + fontSize: 12, + fontWeight: FontWeight.w400, + color: Colors.white70, + ), + ), + ], + ), ), body: stats.when( loading: () => const Center(child: CircularProgressIndicator()), @@ -392,7 +406,11 @@ class _RevenueTrendChart extends StatelessWidget { ), borderData: FlBorderData(show: false), lineTouchData: LineTouchData( + handleBuiltInTouches: true, + touchSpotThreshold: 20, touchTooltipData: LineTouchTooltipData( + fitInsideHorizontally: true, + fitInsideVertically: true, getTooltipItems: (spots) => spots.map((spot) { final day = trend[spot.x.toInt()]; final isRevenue = spot.barIndex == 0; From 114e8e53300116ec2ec1cde614286f35a0b1b561 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:53:44 +0530 Subject: [PATCH 19/26] redesign: enterprise KPI cards with left accent stripe and value-first hierarchy --- lib/shared/widgets/stat_card.dart | 106 +++++++++++++++++++++++------- 1 file changed, 82 insertions(+), 24 deletions(-) diff --git a/lib/shared/widgets/stat_card.dart b/lib/shared/widgets/stat_card.dart index d4b358a..51a0336 100644 --- a/lib/shared/widgets/stat_card.dart +++ b/lib/shared/widgets/stat_card.dart @@ -10,6 +10,7 @@ class StatCard extends StatelessWidget { required this.value, required this.icon, this.color = AppColors.primary, + this.subtitle, this.onTap, }); @@ -17,40 +18,97 @@ class StatCard extends StatelessWidget { final String value; final IconData icon; final Color color; + final String? subtitle; final VoidCallback? onTap; @override Widget build(BuildContext context) { - return Card( + return Material( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: color.withAlpha(25), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, color: color, size: 24), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: AppTextStyles.bodySmall), - const SizedBox(height: 4), - Text(value, style: AppTextStyles.titleLarge), - ], - ), + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.06), + blurRadius: 8, + offset: const Offset(0, 2), ), ], ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container(width: 4, color: color), + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 14, 16, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + label, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 2, + ), + ), + const SizedBox(width: 8), + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 16), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: AppTextStyles.titleLarge.copyWith( + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: 2), + Text( + subtitle!, + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + fontSize: 11, + ), + ), + ], + ], + ), + ], + ), + ), + ), + ], + ), + ), ), ), ); From b09f6bbd032c2c10672ad1cbae18f75ec966dc77 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:54:24 +0530 Subject: [PATCH 20/26] fix: use white text in line chart tooltip for readability --- .../presentation/dashboard_screen.dart | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 1f72756..61cc15e 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -411,24 +411,36 @@ class _RevenueTrendChart extends StatelessWidget { touchTooltipData: LineTouchTooltipData( fitInsideHorizontally: true, fitInsideVertically: true, + tooltipRoundedRadius: 8, + tooltipPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), getTooltipItems: (spots) => spots.map((spot) { final day = trend[spot.x.toInt()]; final isRevenue = spot.barIndex == 0; return LineTooltipItem( - '${isRevenue ? "Revenue" : "GP"}\n${CurrencyUtils.format(spot.y)}', + isRevenue ? dateFmt.format(day.date) : '', AppTextStyles.bodySmall.copyWith( - color: isRevenue ? AppColors.primary : AppColors.success, - fontWeight: FontWeight.w600, + color: Colors.white54, + fontWeight: FontWeight.w400, + fontSize: 11, ), - children: isRevenue - ? [ - TextSpan( - text: '\n${dateFmt.format(day.date)}', - style: AppTextStyles.bodySmall - .copyWith(color: Colors.white70), - ), - ] - : null, + children: [ + TextSpan( + text: '\n${isRevenue ? "Revenue" : "GP"} ', + style: AppTextStyles.bodySmall.copyWith( + color: Colors.white60, + fontWeight: FontWeight.w400, + fontSize: 11, + ), + ), + TextSpan( + text: CurrencyUtils.format(spot.y), + style: AppTextStyles.bodySmall.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13, + ), + ), + ], ); }).toList(), ), From 0b0e27973cbecded128adcd5f9a1dbc302ddd506 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 19:55:04 +0530 Subject: [PATCH 21/26] fix: reduce KPI card height with tighter aspect ratio and padding --- .../dashboard/presentation/dashboard_screen.dart | 6 +++--- lib/shared/widgets/stat_card.dart | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 61cc15e..8f914bc 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -50,9 +50,9 @@ class DashboardScreen extends ConsumerWidget { // ── KPI Grid ──────────────────────────────────────────────── GridView.extent( maxCrossAxisExtent: 300, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 2, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 3, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), children: [ diff --git a/lib/shared/widgets/stat_card.dart b/lib/shared/widgets/stat_card.dart index 51a0336..3d445a4 100644 --- a/lib/shared/widgets/stat_card.dart +++ b/lib/shared/widgets/stat_card.dart @@ -49,7 +49,7 @@ class StatCard extends StatelessWidget { Container(width: 4, color: color), Expanded( child: Padding( - padding: const EdgeInsets.fromLTRB(14, 14, 16, 14), + padding: const EdgeInsets.fromLTRB(12, 10, 14, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -69,13 +69,13 @@ class StatCard extends StatelessWidget { ), const SizedBox(width: 8), Container( - width: 32, - height: 32, + width: 28, + height: 28, decoration: BoxDecoration( color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(6), ), - child: Icon(icon, color: color, size: 16), + child: Icon(icon, color: color, size: 14), ), ], ), @@ -85,7 +85,7 @@ class StatCard extends StatelessWidget { Text( value, style: AppTextStyles.titleLarge.copyWith( - fontSize: 20, + fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), From 6dbd424b9219aa39eb88855c6f213d3f9df705a6 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 23:51:54 +0530 Subject: [PATCH 22/26] feat: add BMS logo and copyright footer to login screen --- .../auth/presentation/login_screen.dart | 181 +++++++++++++----- 1 file changed, 129 insertions(+), 52 deletions(-) diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 0413165..861f627 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -47,64 +47,141 @@ class _LoginScreenState extends ConsumerState { return Scaffold( backgroundColor: AppColors.background, - body: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( - child: Padding( - padding: const EdgeInsets.all(40), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('BMS', style: AppTextStyles.displayLarge), - const SizedBox(height: 8), - Text('Business Management System', style: AppTextStyles.bodySmall), - const SizedBox(height: 40), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Username', - prefixIcon: Icon(Icons.person_outline), + body: Column( + children: [ + Expanded( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + child: Padding( + padding: const EdgeInsets.all(40), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo + Center( + child: Column( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primary, + AppColors.primaryDark, + ], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.35), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: const Center( + child: Text( + 'BMS', + style: TextStyle( + fontFamily: 'Inter', + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 18, + letterSpacing: 2, + ), + ), + ), + ), + const SizedBox(height: 16), + Text( + 'BMS', + style: AppTextStyles.titleLarge.copyWith( + fontWeight: FontWeight.w700, + fontSize: 22, + ), + ), + const SizedBox(height: 4), + Text( + 'Business Management System', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 36), + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + autofocus: true, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility_off + : Icons.visibility), + onPressed: () => setState( + () => _obscurePassword = !_obscurePassword), + ), + ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: authAsync.isLoading ? null : _submit, + child: authAsync.isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text('Sign In'), + ), + ], ), - textInputAction: TextInputAction.next, - autofocus: true, - validator: (v) => (v == null || v.trim().isEmpty) ? 'Required' : null, ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon(_obscurePassword ? Icons.visibility_off : Icons.visibility), - onPressed: () => setState(() => _obscurePassword = !_obscurePassword), - ), - ), - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - validator: (v) => (v == null || v.isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: authAsync.isLoading ? null : _submit, - child: authAsync.isLoading - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), - ) - : const Text('Sign In'), - ), - ], + ), ), ), ), ), - ), + + // Footer + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + '© 2026 BMS. All rights reserved.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textDisabled, + fontSize: 11, + ), + ), + ), + ], ), ); } From afa4348226503720f682bebadc829edeea1e6de5 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 23:55:57 +0530 Subject: [PATCH 23/26] fix: use actual bms_logo.svg asset on login screen --- .../auth/presentation/login_screen.dart | 45 ++----------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 861f627..6012092 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_text_styles.dart'; @@ -66,49 +67,11 @@ class _LoginScreenState extends ConsumerState { Center( child: Column( children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.primary, - AppColors.primaryDark, - ], - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.primary.withValues(alpha: 0.35), - blurRadius: 16, - offset: const Offset(0, 6), - ), - ], - ), - child: const Center( - child: Text( - 'BMS', - style: TextStyle( - fontFamily: 'Inter', - color: Colors.white, - fontWeight: FontWeight.w800, - fontSize: 18, - letterSpacing: 2, - ), - ), - ), + SvgPicture.asset( + 'assets/images/bms_logo.svg', + height: 72, ), const SizedBox(height: 16), - Text( - 'BMS', - style: AppTextStyles.titleLarge.copyWith( - fontWeight: FontWeight.w700, - fontSize: 22, - ), - ), - const SizedBox(height: 4), Text( 'Business Management System', style: AppTextStyles.bodySmall.copyWith( From d4128645ef3dea2d3acf0687faf335262d8ba3de Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 23:56:25 +0530 Subject: [PATCH 24/26] fix: dynamic copyright year on login screen --- lib/features/auth/presentation/login_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 6012092..05101d2 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -137,7 +137,7 @@ class _LoginScreenState extends ConsumerState { Padding( padding: const EdgeInsets.only(bottom: 20), child: Text( - '© 2026 BMS. All rights reserved.', + '© ${DateTime.now().year} BMS. All rights reserved.', style: AppTextStyles.bodySmall.copyWith( color: AppColors.textDisabled, fontSize: 11, From 416dd9ed3dc73e62520cc6688bae6b76497ef676 Mon Sep 17 00:00:00 2001 From: iamvirul Date: Tue, 16 Jun 2026 23:58:18 +0530 Subject: [PATCH 25/26] docs: update changelog with phase 4 dashboard, POS, reports and login changes --- CHANGELOG.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa67812..5cde5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added +- Login screen BMS SVG logo from `assets/images/bms_logo.svg` with dynamic copyright footer (`DateTime.now().year`) +- POS fractional quantity support for weight/volume unit types (`kg`, `g`, `l`, `ml`) - product card tap opens qty dialog with decimal input, stepper uses unit-appropriate increments (0.25 for kg/l, 50 for g/ml), cart displays formatted decimal quantities +- Dashboard 30-day revenue trend line chart with dual series (Revenue solid, Gross Profit dashed) and gradient fill below each line +- Dashboard 7-day grouped bar chart showing Revenue vs Gross Profit side by side per day +- Dashboard MTD card sub-metrics: Gross Profit, Margin %, and Avg Order Value +- Empty state views on Reports screen for P&L (no data), Stock (no stock on hand), and Debtor Aging (all clear) tabs - 88px icon circle, bold title, muted subtitle - Reports screen with three tabs: P&L, Stock Valuation, and Debtor Aging - P&L tab - date range picker, revenue/COGS/gross profit/margin summary cards, daily revenue bar chart with horizontal scrolling for wide ranges - Stock Valuation tab - total stock value card, per-product value list sorted by value with relative progress bar @@ -20,6 +26,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Dependabot configuration for both `pub` and `github-actions` ecosystems with grouped minor/patch updates and co-dependent package groups (drift, riverpod, freezed, go_router) ### Changed +- Dashboard KPI cards redesigned with left accent stripe, value-first hierarchy (large bold value at bottom, muted label at top, small icon bubble top-right), shadow instead of border +- Dashboard KPI card grid `childAspectRatio` increased to 3 for shorter cards with tighter padding +- Dashboard AppBar title split into "Dashboard" headline and date subtitle in white70 - no em-dash separator +- Dashboard 30-day trend replaces the previous 7-day bar chart; provider now fetches 30 days of daily sales with gross profit +- P&L bar chart replaced fixed-width horizontal scroll with a full-width `BarChart` that fills available screen width; zero-revenue day bars rendered in border gray +- P&L summary grid replaced `GridView.count` (fixed aspect ratio causing oversized cards on desktop) with `Column` of `Row(Expanded, Expanded)` pairs for content-driven card height - `InventoryRepository.adjustStock()` accepts optional `movementType` parameter so callers can record `return_in` movements instead of the default `in` - `InvoiceDetailScreen` action row switched from `Row` to `Wrap` to handle three buttons without overflow on narrow screens - Dashboard provider expanded to fetch 7-day trend, payment mix, MTD sales, and last-month sales in a single parallel await using Dart 3 record `.wait` @@ -27,6 +39,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `nextGrnNumber()` in `SuppliersDao` replaced full-table scan with a single `MAX()` aggregate query; O(n) -> O(log n) on the unique index ### Fixed +- Dashboard line chart tooltip text color changed to white - previously used `AppColors.primary` and `AppColors.success` which had poor contrast on the dark tooltip background +- Gross Profit value color on MTD blue card changed to `Color(0xFF69F0AE)` (light mint) for legibility against the primary blue gradient +- Input field label and hint font size reduced to 13sp globally via `InputDecorationTheme` - was inheriting a larger size that looked oversized in dense fields - Code Quality CI workflow now parses `flutter analyze` output for `error` level issues instead of relying on exit code, which behaved inconsistently between macOS and Linux runners - `subosito/flutter-action` action pinned to immutable commit hash to satisfy CodeQL unpinned-action finding - `dart pub audit` removed from CI; command does not exist in Dart 3.12 From 07766a5c46a8d8fff8a8415e69913d8120c5646d Mon Sep 17 00:00:00 2001 From: iamvirul Date: Wed, 17 Jun 2026 00:25:20 +0530 Subject: [PATCH 26/26] fix: address code review findings - atomicity, discount calc, keyboard overflow and polish --- lib/data/database/daos/returns_dao.dart | 11 +- .../auth/presentation/login_screen.dart | 177 ++++++++++-------- .../presentation/dashboard_screen.dart | 8 +- .../presentation/invoice_detail_screen.dart | 69 ++++--- .../pos/presentation/receipt_pdf.dart | 2 +- lib/providers/dashboard_provider.dart | 8 +- lib/providers/pos_provider.dart | 1 + lib/providers/reports_provider.dart | 6 +- lib/shared/widgets/stat_card.dart | 2 +- 9 files changed, 160 insertions(+), 124 deletions(-) diff --git a/lib/data/database/daos/returns_dao.dart b/lib/data/database/daos/returns_dao.dart index 2bd7297..467d699 100644 --- a/lib/data/database/daos/returns_dao.dart +++ b/lib/data/database/daos/returns_dao.dart @@ -9,7 +9,9 @@ part 'returns_dao.g.dart'; class ReturnsDao extends DatabaseAccessor with _$ReturnsDaoMixin { ReturnsDao(super.db); - Future nextReturnNumber() async { + // Generates the next return number atomically. Must be called inside a + // transaction to prevent duplicates under concurrent access. + Future _nextReturnNumber() async { final maxExpr = salesReturns.returnNo.max(); final row = await (selectOnly(salesReturns)..addColumns([maxExpr])).getSingle(); @@ -22,12 +24,17 @@ class ReturnsDao extends DatabaseAccessor with _$ReturnsDaoMixin { return 'RET-${(maxNumber + 1).toString().padLeft(5, '0')}'; } + // Inserts a return and its line items atomically, generating the return + // number inside the transaction to prevent duplicates. Future insertReturnWithItems( SalesReturnsCompanion entry, List items, ) => transaction(() async { - final salesReturn = await into(salesReturns).insertReturning(entry); + final returnNo = await _nextReturnNumber(); + final salesReturn = await into(salesReturns).insertReturning( + entry.copyWith(returnNo: Value(returnNo)), + ); await batch((b) => b.insertAll(returnItems, items)); return salesReturn; }); diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart index 05101d2..1056392 100644 --- a/lib/features/auth/presentation/login_screen.dart +++ b/lib/features/auth/presentation/login_screen.dart @@ -48,103 +48,118 @@ class _LoginScreenState extends ConsumerState { return Scaffold( backgroundColor: AppColors.background, - body: Column( - children: [ - Expanded( - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Card( + // SafeArea + SingleChildScrollView prevents the form from being hidden + // behind the soft keyboard on short screens. + body: SafeArea( + child: SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: MediaQuery.of(context).size.height - + MediaQuery.of(context).padding.top - + MediaQuery.of(context).padding.bottom, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Center( child: Padding( - padding: const EdgeInsets.all(40), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Logo - Center( + padding: const EdgeInsets.symmetric(vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Card( + child: Padding( + padding: const EdgeInsets.all(40), + child: Form( + key: _formKey, child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - SvgPicture.asset( - 'assets/images/bms_logo.svg', - height: 72, + Center( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/bms_logo.svg', + height: 72, + ), + const SizedBox(height: 16), + Text( + 'Business Management System', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(height: 36), + TextFormField( + controller: _usernameController, + decoration: const InputDecoration( + labelText: 'Username', + prefixIcon: Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + autofocus: true, + validator: (v) => + (v == null || v.trim().isEmpty) + ? 'Required' + : null, ), const SizedBox(height: 16), - Text( - 'Business Management System', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textSecondary, + TextFormField( + controller: _passwordController, + obscureText: _obscurePassword, + decoration: InputDecoration( + labelText: 'Password', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon(_obscurePassword + ? Icons.visibility_off + : Icons.visibility), + onPressed: () => setState(() => + _obscurePassword = !_obscurePassword), + ), ), + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + validator: (v) => + (v == null || v.isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: authAsync.isLoading ? null : _submit, + child: authAsync.isLoading + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white), + ) + : const Text('Sign In'), ), ], ), ), - const SizedBox(height: 36), - TextFormField( - controller: _usernameController, - decoration: const InputDecoration( - labelText: 'Username', - prefixIcon: Icon(Icons.person_outline), - ), - textInputAction: TextInputAction.next, - autofocus: true, - validator: (v) => - (v == null || v.trim().isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscurePassword, - decoration: InputDecoration( - labelText: 'Password', - prefixIcon: const Icon(Icons.lock_outline), - suffixIcon: IconButton( - icon: Icon(_obscurePassword - ? Icons.visibility_off - : Icons.visibility), - onPressed: () => setState( - () => _obscurePassword = !_obscurePassword), - ), - ), - textInputAction: TextInputAction.done, - onFieldSubmitted: (_) => _submit(), - validator: (v) => - (v == null || v.isEmpty) ? 'Required' : null, - ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: authAsync.isLoading ? null : _submit, - child: authAsync.isLoading - ? const SizedBox.square( - dimension: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white), - ) - : const Text('Sign In'), - ), - ], + ), ), ), ), ), - ), - ), - ), - - // Footer - Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Text( - '© ${DateTime.now().year} BMS. All rights reserved.', - style: AppTextStyles.bodySmall.copyWith( - color: AppColors.textDisabled, - fontSize: 11, - ), + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Text( + '© ${DateTime.now().year} BMS. All rights reserved.', + style: AppTextStyles.bodySmall.copyWith( + color: AppColors.textDisabled, + fontSize: 11, + ), + ), + ), + ], ), ), - ], + ), ), ); } diff --git a/lib/features/dashboard/presentation/dashboard_screen.dart b/lib/features/dashboard/presentation/dashboard_screen.dart index 8f914bc..c7033c6 100644 --- a/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/lib/features/dashboard/presentation/dashboard_screen.dart @@ -120,10 +120,7 @@ class DashboardScreen extends ConsumerWidget { subtitle: 'Current month by method', ), const SizedBox(height: 12), - _PaymentMixCard( - mix: s.paymentMix, - totalSales: s.mtdSales, - ), + _PaymentMixCard(mix: s.paymentMix), ], // ── Recent Invoices ────────────────────────────────────────── @@ -696,9 +693,8 @@ class _WeeklyGroupedChart extends StatelessWidget { // ── Payment Mix Card ───────────────────────────────────────────────────────── class _PaymentMixCard extends StatelessWidget { - const _PaymentMixCard({required this.mix, required this.totalSales}); + const _PaymentMixCard({required this.mix}); final Map mix; - final double totalSales; static const _colorMap = { 'cash': AppColors.success, diff --git a/lib/features/invoices/presentation/invoice_detail_screen.dart b/lib/features/invoices/presentation/invoice_detail_screen.dart index 982f4f2..9be1d19 100644 --- a/lib/features/invoices/presentation/invoice_detail_screen.dart +++ b/lib/features/invoices/presentation/invoice_detail_screen.dart @@ -544,8 +544,11 @@ class _ProcessReturnSheetState extends ConsumerState<_ProcessReturnSheet> { double get _totalReturnAmount { double total = 0; for (int i = 0; i < widget.detail.items.length; i++) { + final item = widget.detail.items[i]; final qty = double.tryParse(_qtyControllers[i].text.trim()) ?? 0; - if (qty > 0) total += widget.detail.items[i].unitPrice * qty; + if (qty > 0 && item.qty > 0) { + total += (item.subtotal / item.qty) * qty; + } } return total; } @@ -580,14 +583,16 @@ class _ProcessReturnSheetState extends ConsumerState<_ProcessReturnSheet> { const uuid = Uuid(); final returnId = uuid.v7(); - final returnNo = await returnsDao.nextReturnNumber(); - final totalAmount = returnItems.fold( - 0.0, (s, e) => s + e.item.unitPrice * e.qty); + // Use discounted unit price (subtotal / qty) so partial returns respect + // any line-item discount the customer originally received. + final totalAmount = returnItems.fold(0.0, (s, e) { + if (e.item.qty <= 0) return s; + return s + (e.item.subtotal / e.item.qty) * e.qty; + }); final entry = SalesReturnsCompanion( id: Value(returnId), invoiceId: Value(widget.detail.invoice.id), - returnNo: Value(returnNo), type: Value(_type), totalAmount: Value(totalAmount), reason: Value( @@ -596,31 +601,39 @@ class _ProcessReturnSheetState extends ConsumerState<_ProcessReturnSheet> { ); final items = returnItems - .map((e) => ReturnItemsCompanion( - id: Value(uuid.v7()), - returnId: Value(returnId), - productId: Value(e.item.productId), - productName: Value(e.item.productName), - qty: Value(e.qty), - unitPrice: Value(e.item.unitPrice), - subtotal: Value(e.item.unitPrice * e.qty), - )) + .map((e) { + final effectiveUnitPrice = + e.item.qty > 0 ? e.item.subtotal / e.item.qty : e.item.unitPrice; + return ReturnItemsCompanion( + id: Value(uuid.v7()), + returnId: Value(returnId), + productId: Value(e.item.productId), + productName: Value(e.item.productName), + qty: Value(e.qty), + unitPrice: Value(effectiveUnitPrice), + subtotal: Value(effectiveUnitPrice * e.qty), + ); + }) .toList(); - await returnsDao.insertReturnWithItems(entry, items); - - for (final e in returnItems) { - await inventoryRepo.adjustStock( - productId: e.item.productId, - delta: e.qty, - reason: 'Sales return $returnNo', - userId: userId, - userName: userName, - refId: returnId, - refType: 'sales_return', - movementType: 'return_in', - ); - } + // Wrap insert + all stock adjustments in a single transaction so that + // a failed stock write rolls back the return record too. + late SalesReturn inserted; + await returnsDao.transaction(() async { + inserted = await returnsDao.insertReturnWithItems(entry, items); + for (final e in returnItems) { + await inventoryRepo.adjustStock( + productId: e.item.productId, + delta: e.qty, + reason: 'Sales return ${inserted.returnNo}', + userId: userId, + userName: userName, + refId: returnId, + refType: 'sales_return', + movementType: 'return_in', + ); + } + }); if (mounted) Navigator.pop(context, true); } catch (e) { diff --git a/lib/features/pos/presentation/receipt_pdf.dart b/lib/features/pos/presentation/receipt_pdf.dart index 78c3cbe..f30adac 100644 --- a/lib/features/pos/presentation/receipt_pdf.dart +++ b/lib/features/pos/presentation/receipt_pdf.dart @@ -275,7 +275,7 @@ class _ItemRow extends pw.StatelessWidget { pw.Widget build(pw.Context context) { final qtyStr = item.qty % 1 == 0 ? item.qty.toStringAsFixed(0) - : item.qty.toStringAsFixed(1); + : item.qty.toStringAsFixed(3).replaceAll(RegExp(r'\.?0+$'), ''); return pw.Padding( padding: const pw.EdgeInsets.only(bottom: 4), diff --git a/lib/providers/dashboard_provider.dart b/lib/providers/dashboard_provider.dart index da9cd57..60152f1 100644 --- a/lib/providers/dashboard_provider.dart +++ b/lib/providers/dashboard_provider.dart @@ -45,8 +45,12 @@ class DashboardStats { return salesTrend.sublist(salesTrend.length - 7); } - double get mtdGrossProfit => - salesTrend.fold(0.0, (s, d) => s + d.grossProfit); + double get mtdGrossProfit { + final now = DateTime.now(); + return salesTrend + .where((d) => d.date.year == now.year && d.date.month == now.month) + .fold(0.0, (s, d) => s + d.grossProfit); + } double get mtdGrossMarginPct { if (mtdSales == 0) return 0; diff --git a/lib/providers/pos_provider.dart b/lib/providers/pos_provider.dart index abaa9a8..f71ac40 100644 --- a/lib/providers/pos_provider.dart +++ b/lib/providers/pos_provider.dart @@ -86,6 +86,7 @@ class PosNotifier extends _$PosNotifier { PosState build() => const PosState(); void addItem(Product product, {double qty = 1}) { + if (qty <= 0) return; final items = List.from(state.items); final idx = items.indexWhere((i) => i.product.id == product.id); if (idx >= 0) { diff --git a/lib/providers/reports_provider.dart b/lib/providers/reports_provider.dart index 3ca70ce..bd9c5db 100644 --- a/lib/providers/reports_provider.dart +++ b/lib/providers/reports_provider.dart @@ -6,12 +6,12 @@ part 'reports_provider.g.dart'; @riverpod Future> dailySales(Ref ref, DateTime from, DateTime to) => - ref.read(reportsDaoProvider).getDailySales(from, to); + ref.watch(reportsDaoProvider).getDailySales(from, to); @riverpod Future> stockValuation(Ref ref) => - ref.read(reportsDaoProvider).getStockValuation(); + ref.watch(reportsDaoProvider).getStockValuation(); @riverpod Future> debtorAging(Ref ref) => - ref.read(reportsDaoProvider).getDebtorAging(); + ref.watch(reportsDaoProvider).getDebtorAging(); diff --git a/lib/shared/widgets/stat_card.dart b/lib/shared/widgets/stat_card.dart index 3d445a4..fe88eb2 100644 --- a/lib/shared/widgets/stat_card.dart +++ b/lib/shared/widgets/stat_card.dart @@ -29,7 +29,7 @@ class StatCard extends StatelessWidget { child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), - child: Container( + child: Ink( decoration: BoxDecoration( color: AppColors.surface, borderRadius: BorderRadius.circular(12),