import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; /// Scrollable page body shared by every module screen. /// /// Always scrolls vertically, so no module can overflow no matter how short /// the viewport gets. class ModulePage extends StatelessWidget { const ModulePage({ super.key, required this.children, this.padding = AppSpacing.xxl, }); final List children; final double padding; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: EdgeInsets.all(padding), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: children, ), ); } } /// KPI card. Designed to sit inside a [Wrap] so it reflows instead of /// overflowing when the window narrows. class StatTile extends StatelessWidget { const StatTile({ super.key, required this.label, required this.value, required this.icon, this.color = AppColors.primary, this.delta, this.deltaPositive = true, this.caption, this.width = 232, }); final String label; final String value; final IconData icon; final Color color; final String? delta; final bool deltaPositive; final String? caption; final double width; @override Widget build(BuildContext context) { return Container( width: width, padding: const EdgeInsets.all(AppSpacing.lg), decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: AppRadius.brSm, ), child: Icon(icon, size: 17, color: color), ), const SizedBox(width: AppSpacing.sm), Expanded( child: Text( label, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 12.5, fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), ), ], ), const SizedBox(height: AppSpacing.md), FittedBox( fit: BoxFit.scaleDown, alignment: Alignment.centerLeft, child: Text(value, style: AppTypography.money(24)), ), if (delta != null || caption != null) ...[ const SizedBox(height: AppSpacing.xs), Row( children: [ if (delta != null) ...[ Icon( deltaPositive ? Icons.trending_up_rounded : Icons.trending_down_rounded, size: 14, color: deltaPositive ? AppColors.success : AppColors.danger, ), const SizedBox(width: 3), Text( delta!, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: deltaPositive ? AppColors.success : AppColors.danger, ), ), const SizedBox(width: AppSpacing.xs), ], if (caption != null) Expanded( child: Text( caption!, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 11.5, color: AppColors.textTertiary, ), ), ), ], ), ], ], ), ); } } /// Titled container for a block of module content. class PanelCard extends StatelessWidget { const PanelCard({ super.key, required this.title, required this.child, this.subtitle, this.action, this.padding = AppSpacing.lg, }); final String title; final Widget child; final String? subtitle; final Widget? action; final double padding; @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( color: AppColors.surface, borderRadius: AppRadius.brLg, border: Border.all(color: AppColors.border), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: EdgeInsets.fromLTRB(padding, padding, padding, AppSpacing.md), // Wrap so a long title plus an action never collide. child: Wrap( alignment: WrapAlignment.spaceBetween, crossAxisAlignment: WrapCrossAlignment.center, spacing: AppSpacing.md, runSpacing: AppSpacing.sm, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( title, style: const TextStyle( fontSize: 15.5, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), if (subtitle != null) Text( subtitle!, style: const TextStyle( fontSize: 12.5, color: AppColors.textTertiary, ), ), ], ), if (action != null) action!, ], ), ), const Divider(height: 1), Padding(padding: EdgeInsets.all(padding), child: child), ], ), ); } } /// One column of a [ResponsiveTable]. class TableCol { const TableCol( this.label, { this.flex = 2, this.numeric = false, this.priority = 0, }); final String label; final int flex; final bool numeric; /// Higher numbers are dropped first as the table narrows. final int priority; } /// Table that degrades into stacked cards rather than overflowing. /// /// Above [stackBelow] it renders as aligned columns; below, each row becomes a /// label/value card. Low-priority columns are hidden at intermediate widths. class ResponsiveTable extends StatelessWidget { const ResponsiveTable({ super.key, required this.columns, required this.rows, this.stackBelow = 620, this.hideSecondaryBelow = 900, this.onRowTap, }); final List columns; /// Each row must supply exactly one cell per column. final List> rows; final double stackBelow; final double hideSecondaryBelow; final void Function(int index)? onRowTap; @override Widget build(BuildContext context) { if (rows.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: AppSpacing.xxl), child: Center( child: Text( 'Nothing to show yet.', style: TextStyle(color: AppColors.textTertiary), ), ), ); } return LayoutBuilder( builder: (context, constraints) { final w = constraints.maxWidth; if (w < stackBelow) return _stacked(); final visible = [ for (var i = 0; i < columns.length; i++) if (w >= hideSecondaryBelow || columns[i].priority == 0) i, ]; return Column( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.only(bottom: AppSpacing.sm), child: Row( children: [ for (final i in visible) Expanded( flex: columns[i].flex, child: Text( columns[i].label.toUpperCase(), textAlign: columns[i].numeric ? TextAlign.right : TextAlign.left, overflow: TextOverflow.ellipsis, style: AppTypography.sectionLabel(), ), ), ], ), ), const Divider(height: 1), for (var r = 0; r < rows.length; r++) InkWell( onTap: onRowTap == null ? null : () => onRowTap!(r), borderRadius: AppRadius.brXs, child: Container( padding: const EdgeInsets.symmetric(vertical: AppSpacing.md), decoration: const BoxDecoration( border: Border(bottom: BorderSide(color: AppColors.divider)), ), child: Row( children: [ for (final i in visible) Expanded( flex: columns[i].flex, child: Align( alignment: columns[i].numeric ? Alignment.centerRight : Alignment.centerLeft, child: rows[r][i], ), ), ], ), ), ), ], ); }, ); } Widget _stacked() { return Column( mainAxisSize: MainAxisSize.min, children: [ for (var r = 0; r < rows.length; r++) Container( margin: const EdgeInsets.only(bottom: AppSpacing.sm), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration( color: AppColors.surfaceAlt, borderRadius: AppRadius.brSm, border: Border.all(color: AppColors.border), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ for (var c = 0; c < columns.length; c++) Padding( padding: const EdgeInsets.symmetric(vertical: 3), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 104, child: Text( columns[c].label, style: const TextStyle( fontSize: 12, color: AppColors.textTertiary, ), ), ), Expanded( child: Align( alignment: Alignment.centerLeft, child: rows[r][c], ), ), ], ), ), ], ), ), ], ); } } /// Plain text cell. class Cell extends StatelessWidget { const Cell( this.text, { super.key, this.bold = false, this.color, this.mono = false, }); final String text; final bool bold; final Color? color; final bool mono; @override Widget build(BuildContext context) { return Text( text, overflow: TextOverflow.ellipsis, style: mono ? AppTypography.money(13.5, weight: bold ? FontWeight.w700 : FontWeight.w500, color: color,) : TextStyle( fontSize: 13.5, fontWeight: bold ? FontWeight.w600 : FontWeight.w400, color: color ?? AppColors.textPrimary, ), ); } } /// Small coloured status label. class TagChip extends StatelessWidget { const TagChip(this.label, {super.key, this.color = AppColors.primary}); final String label; final Color color; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: AppRadius.brPill, ), child: Text( label, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: color, ), ), ); } } /// Lightweight bar chart painted in code, so no charting dependency is needed. class MiniBarChart extends StatelessWidget { const MiniBarChart({ super.key, required this.values, required this.labels, this.height = 180, this.color = AppColors.primary, }); final List values; final List labels; final double height; final Color color; @override Widget build(BuildContext context) { if (values.isEmpty) return SizedBox(height: height); final max = values.reduce((a, b) => a > b ? a : b); return SizedBox( height: height, child: LayoutBuilder( builder: (context, constraints) { // Labels are dropped rather than squeezed when space is tight. final showLabels = constraints.maxWidth / values.length >= 28; return Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ for (var i = 0; i < values.length; i++) Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Expanded( child: FractionallySizedBox( alignment: Alignment.bottomCenter, heightFactor: max <= 0 ? 0 : (values[i] / max).clamp(0.03, 1), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ color, color.withValues(alpha: 0.45), ], ), borderRadius: const BorderRadius.vertical( top: Radius.circular(5), ), ), ), ), ), if (showLabels) ...[ const SizedBox(height: AppSpacing.sm), Text( labels[i], maxLines: 1, overflow: TextOverflow.clip, style: const TextStyle( fontSize: 10.5, color: AppColors.textTertiary, ), ), ], ], ), ), ), ], ); }, ), ); } } /// Horizontal proportion bar used for breakdowns. class ProgressRow extends StatelessWidget { const ProgressRow({ super.key, required this.label, required this.value, required this.fraction, this.color = AppColors.primary, }); final String label; final String value; final double fraction; final Color color; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: Text( label, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13), ), ), const SizedBox(width: AppSpacing.sm), Text(value, style: AppTypography.money(13)), ], ), const SizedBox(height: AppSpacing.sm), ClipRRect( borderRadius: AppRadius.brPill, child: LinearProgressIndicator( value: fraction.clamp(0, 1), minHeight: 6, backgroundColor: AppColors.divider, valueColor: AlwaysStoppedAnimation(color), ), ), ], ), ); } }