581 lines
17 KiB
Dart
581 lines
17 KiB
Dart
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),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|