UI Phase 2 (L0): shared layout primitives + chrome standardization
Extract four shared layout widgets from the best existing implementations and adopt them across list/overview screens, deleting duplicated inline code. Layout/consistency only — no behavior change. New primitives (lib/widgets/): - app_section_header.dart — AppSectionHeader (azure accent bar + uppercase telemetry label; promoted from the dashboard's _sectionTitle). - app_metric_card.dart — AppMetricCard (uppercase label + mono value on a flat hairline surface; unifies KPI/stat tiles). - app_status_summary.dart — AppStatusSummaryRow/Item/Chip (dot + count + label, selectable; semantic AppStatusColors). - app_filter_bar.dart — AppFilterBar (responsive filter-row chrome). Adopted / de-duplicated: - Dashboard: _MetricCard now wraps AppMetricCard; section titles use AppSectionHeader (removed local _sectionTitle). - Tickets / Tasks / ISR: three separate summary-chip implementations (_StatusSummaryCard x2, _SummaryChip) collapsed into AppStatusSummaryRow. - Network Map overview: Sites/Devices tiles -> AppMetricCard (removed local _SummaryCard). - Workforce: "Duty Schedules" header -> AppSectionHeader with trailing chip. Verified: flutter analyze lib clean; flutter build web green; visual QA (tickets + ISR summary chips) in dark. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Standardized filter-bar chrome for list screens.
|
||||
///
|
||||
/// Wraps a screen's filter controls (search field, dropdowns, date-range
|
||||
/// button, etc.) in a consistent, responsive [Wrap] so every list screen's
|
||||
/// filter row shares the same spacing and wrap behavior. Callers keep building
|
||||
/// their own controls; sizing individual controls with a [SizedBox] width is
|
||||
/// still fine — this only standardizes the layout around them.
|
||||
class AppFilterBar extends StatelessWidget {
|
||||
const AppFilterBar({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.padding = const EdgeInsets.symmetric(vertical: 4),
|
||||
this.spacing = 12,
|
||||
this.runSpacing = 12,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final double spacing;
|
||||
final double runSpacing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (children.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Wrap(
|
||||
spacing: spacing,
|
||||
runSpacing: runSpacing,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: children,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_surfaces.dart';
|
||||
import 'mono_text.dart';
|
||||
|
||||
/// Cyber/IT metric / stat card — an uppercase telemetry label above a large
|
||||
/// monospace value, on a flat tonal surface with a hairline outline.
|
||||
///
|
||||
/// The single shared style for KPI/stat tiles (unifies the Dashboard KPI card,
|
||||
/// the Network Map "Sites/Devices" cards, and the Reports conversion tile).
|
||||
/// Purely presentational: callers pass already-resolved [title]/[value] strings.
|
||||
class AppMetricCard extends StatelessWidget {
|
||||
const AppMetricCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.icon,
|
||||
this.onTap,
|
||||
this.trailing,
|
||||
this.animateValue = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String value;
|
||||
|
||||
/// Optional leading accent glyph.
|
||||
final IconData? icon;
|
||||
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Optional trailing widget (e.g. a chevron for drill-down cards).
|
||||
final Widget? trailing;
|
||||
|
||||
/// Cross-fade the value when it changes (nice for live-updating metrics).
|
||||
final bool animateValue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
final radius = AppSurfaces.of(context).cardRadius;
|
||||
|
||||
final valueWidget = MonoText(
|
||||
value,
|
||||
key: ValueKey(value),
|
||||
style: tt.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
|
||||
final content = Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 22, color: cs.primary),
|
||||
const SizedBox(width: 14),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: tt.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (animateValue)
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, anim) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
child: valueWidget,
|
||||
)
|
||||
else
|
||||
valueWidget,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final decorated = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
|
||||
if (onTap == null) return decorated;
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: decorated,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Cyber/IT section header — a short accent bar + an uppercase, letter-spaced
|
||||
/// "telemetry" label, with optional trailing actions.
|
||||
///
|
||||
/// This is the single shared treatment for in-page section titles (promoted
|
||||
/// from the Dashboard's original `_sectionTitle`). Use it instead of ad-hoc
|
||||
/// bold `Text` sub-headers so every screen's sections read the same way.
|
||||
class AppSectionHeader extends StatelessWidget {
|
||||
const AppSectionHeader(
|
||||
this.title, {
|
||||
super.key,
|
||||
this.trailing,
|
||||
this.padding = const EdgeInsets.only(bottom: 12),
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// Optional trailing widget (e.g. a "Show past" button or filter chip),
|
||||
/// pinned to the end of the header row.
|
||||
final Widget? trailing;
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/app_status_colors.dart';
|
||||
import '../theme/app_surfaces.dart';
|
||||
|
||||
/// One entry in an [AppStatusSummaryRow].
|
||||
class AppStatusSummaryItem {
|
||||
const AppStatusSummaryItem({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.kind,
|
||||
this.selected = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final StatusKind kind;
|
||||
|
||||
/// Emphasized (filled) when true — used for the active filter.
|
||||
final bool selected;
|
||||
|
||||
/// When set, the chip is tappable (e.g. to filter the list by this status).
|
||||
final VoidCallback? onTap;
|
||||
}
|
||||
|
||||
/// A horizontally-scrollable row of status-count chips — the single shared
|
||||
/// "count-by-status" summary used above entity lists (unifies the separate
|
||||
/// tickets / tasks / ISR summary-chip implementations).
|
||||
class AppStatusSummaryRow extends StatelessWidget {
|
||||
const AppStatusSummaryRow({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.padding = EdgeInsets.zero,
|
||||
});
|
||||
|
||||
final List<AppStatusSummaryItem> items;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: padding,
|
||||
child: Row(
|
||||
children: [
|
||||
for (var i = 0; i < items.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
AppStatusSummaryChip(item: items[i]),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single status-count chip: a status dot + bold count + label. Filled with
|
||||
/// the status tone when [AppStatusSummaryItem.selected], subtle otherwise.
|
||||
class AppStatusSummaryChip extends StatelessWidget {
|
||||
const AppStatusSummaryChip({super.key, required this.item});
|
||||
|
||||
final AppStatusSummaryItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final swatch = AppStatusColors.of(context).byKind(item.kind);
|
||||
final radius = AppSurfaces.of(context).chipRadius;
|
||||
|
||||
final bg = item.selected ? swatch.container : cs.surfaceContainerHighest;
|
||||
final labelColor =
|
||||
item.selected ? swatch.onContainer : cs.onSurfaceVariant;
|
||||
final countColor = item.selected ? swatch.onContainer : cs.onSurface;
|
||||
|
||||
final chip = AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
border: Border.all(
|
||||
color: item.selected ? Colors.transparent : cs.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: swatch.color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
item.count.toString(),
|
||||
style: tt.labelLarge?.copyWith(
|
||||
color: countColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
item.label.toUpperCase(),
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: labelColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (item.onTap == null) return chip;
|
||||
return InkWell(
|
||||
onTap: item.onTap,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: chip,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user