Files
tasq/lib/widgets/app_metric_card.dart
redz1029 9f764150a2 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>
2026-09-26 15:42:26 +08:00

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,
),
);
}
}