9f764150a2
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>
120 lines
3.6 KiB
Dart
120 lines
3.6 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|