second commit
This commit is contained in:
580
lib/presentation/modules/widgets/module_widgets.dart
Normal file
580
lib/presentation/modules/widgets/module_widgets.dart
Normal file
@@ -0,0 +1,580 @@
|
||||
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<Widget> 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<TableCol> columns;
|
||||
|
||||
/// Each row must supply exactly one cell per column.
|
||||
final List<List<Widget>> 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 = <int>[
|
||||
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<double> values;
|
||||
final List<String> 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user