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:
@@ -40,7 +40,9 @@ import 'package:skeletonizer/skeletonizer.dart';
|
|||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../theme/app_status_colors.dart';
|
import '../../theme/app_status_colors.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
|
import '../../widgets/app_metric_card.dart';
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_section_header.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
|
||||||
class DashboardMetrics {
|
class DashboardMetrics {
|
||||||
@@ -673,13 +675,13 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final sections = <Widget>[
|
final sections = <Widget>[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_sectionTitle(context, 'IT Staff Pulse'),
|
const AppSectionHeader('IT Staff Pulse'),
|
||||||
const _StaffTable(),
|
const _StaffTable(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_sectionTitle(context, 'Team Activity'),
|
const AppSectionHeader('Team Activity'),
|
||||||
const _StaffActivityChart(),
|
const _StaffActivityChart(),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_sectionTitle(context, 'Core Daily KPIs'),
|
const AppSectionHeader('Core Daily KPIs'),
|
||||||
_cardGrid(context, [
|
_cardGrid(context, [
|
||||||
_MetricCard(
|
_MetricCard(
|
||||||
title: 'New tickets today',
|
title: 'New tickets today',
|
||||||
@@ -695,7 +697,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_sectionTitle(context, 'Task Flow'),
|
const AppSectionHeader('Task Flow'),
|
||||||
_cardGrid(context, [
|
_cardGrid(context, [
|
||||||
_MetricCard(
|
_MetricCard(
|
||||||
title: 'Tasks created',
|
title: 'Tasks created',
|
||||||
@@ -713,7 +715,7 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_sectionTitle(context, 'TAT / Response'),
|
const AppSectionHeader('TAT / Response'),
|
||||||
_cardGrid(context, [
|
_cardGrid(context, [
|
||||||
_MetricCard(
|
_MetricCard(
|
||||||
title: 'Avg response',
|
title: 'Avg response',
|
||||||
@@ -776,33 +778,6 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _sectionTitle(BuildContext context, String title) {
|
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 3,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: cs.primary,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
title.toUpperCase(),
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: 0.8,
|
|
||||||
color: cs.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _cardGrid(BuildContext context, List<Widget> cards) {
|
Widget _cardGrid(BuildContext context, List<Widget> cards) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
@@ -921,7 +896,6 @@ class _MetricCard extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
// Only watch the single string value for this card so unrelated metric
|
// Only watch the single string value for this card so unrelated metric
|
||||||
// updates don't rebuild the whole card. This makes updates feel much
|
// updates don't rebuild the whole card. This makes updates feel much
|
||||||
// smoother and avoids full-page refreshes.
|
// smoother and avoids full-page refreshes.
|
||||||
@@ -934,51 +908,7 @@ class _MetricCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final isDark = cs.brightness == Brightness.dark;
|
return AppMetricCard(title: title, value: value);
|
||||||
return AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 400),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppSurfaces.of(context).cardRadius,
|
|
||||||
),
|
|
||||||
border: Border.all(color: cs.outlineVariant),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title.toUpperCase(),
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
color: cs.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Animate only the metric text (not the whole card) for a
|
|
||||||
// subtle, smooth update.
|
|
||||||
AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 400),
|
|
||||||
switchInCurve: Curves.easeOutCubic,
|
|
||||||
switchOutCurve: Curves.easeInCubic,
|
|
||||||
transitionBuilder: (child, anim) =>
|
|
||||||
FadeTransition(opacity: anim, child: child),
|
|
||||||
child: MonoText(
|
|
||||||
value,
|
|
||||||
key: ValueKey(value),
|
|
||||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: cs.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import '../../theme/m3_motion.dart';
|
|||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../theme/app_status_colors.dart';
|
import '../../theme/app_status_colors.dart';
|
||||||
|
import '../../widgets/app_status_summary.dart';
|
||||||
import '../../widgets/m3_card.dart';
|
import '../../widgets/m3_card.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
@@ -535,8 +536,6 @@ class _StatusSummaryRow extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
final status = AppStatusColors.of(context);
|
|
||||||
final pending = requests
|
final pending = requests
|
||||||
.where((r) => r.status == ItServiceRequestStatus.pendingApproval)
|
.where((r) => r.status == ItServiceRequestStatus.pendingApproval)
|
||||||
.length;
|
.length;
|
||||||
@@ -554,88 +553,38 @@ class _StatusSummaryRow extends StatelessWidget {
|
|||||||
.where((r) => r.status == ItServiceRequestStatus.completed)
|
.where((r) => r.status == ItServiceRequestStatus.completed)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return AppStatusSummaryRow(
|
||||||
scrollDirection: Axis.horizontal,
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
items: [
|
||||||
child: Row(
|
AppStatusSummaryItem(
|
||||||
children: [
|
label: 'Pending',
|
||||||
_SummaryChip(
|
count: pending,
|
||||||
label: 'Pending',
|
kind: StatusKind.warning,
|
||||||
count: pending,
|
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
|
||||||
color: status.warning.color,
|
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
|
||||||
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
|
|
||||||
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_SummaryChip(
|
|
||||||
label: 'Scheduled',
|
|
||||||
count: scheduled,
|
|
||||||
color: status.info.color,
|
|
||||||
selected: selectedStatus == ItServiceRequestStatus.scheduled,
|
|
||||||
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_SummaryChip(
|
|
||||||
label: 'In Progress',
|
|
||||||
count: inProgress,
|
|
||||||
color: cs.secondary,
|
|
||||||
selected: selectedStatus == ItServiceRequestStatus.inProgress,
|
|
||||||
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_SummaryChip(
|
|
||||||
label: 'Completed',
|
|
||||||
count: completed,
|
|
||||||
color: status.success.color,
|
|
||||||
selected: selectedStatus == ItServiceRequestStatus.completed,
|
|
||||||
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SummaryChip extends StatelessWidget {
|
|
||||||
const _SummaryChip({
|
|
||||||
required this.label,
|
|
||||||
required this.count,
|
|
||||||
required this.color,
|
|
||||||
required this.selected,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final int count;
|
|
||||||
final Color color;
|
|
||||||
final bool selected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return M3Card.filled(
|
|
||||||
onTap: onTap,
|
|
||||||
color: selected
|
|
||||||
? color.withValues(alpha: 0.2)
|
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
count.toString(),
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(label, style: Theme.of(context).textTheme.bodySmall),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
AppStatusSummaryItem(
|
||||||
|
label: 'Scheduled',
|
||||||
|
count: scheduled,
|
||||||
|
kind: StatusKind.neutral,
|
||||||
|
selected: selectedStatus == ItServiceRequestStatus.scheduled,
|
||||||
|
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
|
||||||
|
),
|
||||||
|
AppStatusSummaryItem(
|
||||||
|
label: 'In Progress',
|
||||||
|
count: inProgress,
|
||||||
|
kind: StatusKind.info,
|
||||||
|
selected: selectedStatus == ItServiceRequestStatus.inProgress,
|
||||||
|
onTap: () => onStatusTap(ItServiceRequestStatus.inProgress),
|
||||||
|
),
|
||||||
|
AppStatusSummaryItem(
|
||||||
|
label: 'Completed',
|
||||||
|
count: completed,
|
||||||
|
kind: StatusKind.success,
|
||||||
|
selected: selectedStatus == ItServiceRequestStatus.completed,
|
||||||
|
onTap: () => onStatusTap(ItServiceRequestStatus.completed),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../providers/network_map/network_devices_provider.dart';
|
|||||||
import '../../providers/network_map/network_sites_provider.dart';
|
import '../../providers/network_map/network_sites_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/app_metric_card.dart';
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/app_state_view.dart';
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
@@ -167,41 +168,24 @@ class _SummaryRow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _SummaryCard(label: 'Sites', value: siteCount.toString(), icon: Icons.location_city_outlined)),
|
Expanded(
|
||||||
const SizedBox(width: 12),
|
child: AppMetricCard(
|
||||||
Expanded(child: _SummaryCard(label: 'Devices', value: deviceCount.toString(), icon: Icons.devices_outlined)),
|
title: 'Sites',
|
||||||
],
|
value: siteCount.toString(),
|
||||||
);
|
icon: Icons.location_city_outlined,
|
||||||
}
|
animateValue: false,
|
||||||
}
|
),
|
||||||
|
|
||||||
class _SummaryCard extends StatelessWidget {
|
|
||||||
const _SummaryCard({required this.label, required this.value, required this.icon});
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final IconData icon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final tt = Theme.of(context).textTheme;
|
|
||||||
final cs = Theme.of(context).colorScheme;
|
|
||||||
return Card(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: cs.primary),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(label, style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant)),
|
|
||||||
Text(value, style: tt.headlineSmall?.copyWith(fontWeight: FontWeight.w700)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: AppMetricCard(
|
||||||
|
title: 'Devices',
|
||||||
|
value: deviceCount.toString(),
|
||||||
|
icon: Icons.devices_outlined,
|
||||||
|
animateValue: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import '../../widgets/tasq_adaptive_list.dart';
|
|||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../theme/app_status_colors.dart';
|
import '../../theme/app_status_colors.dart';
|
||||||
|
import '../../widgets/app_status_summary.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/app_breakpoints.dart';
|
import '../../widgets/app_breakpoints.dart';
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
@@ -1329,63 +1330,15 @@ class _StatusSummaryRow extends StatelessWidget {
|
|||||||
final entries = counts.entries.toList()
|
final entries = counts.entries.toList()
|
||||||
..sort((a, b) => a.key.compareTo(b.key));
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return AppStatusSummaryRow(
|
||||||
scrollDirection: Axis.horizontal,
|
items: [
|
||||||
child: Row(
|
for (final e in entries)
|
||||||
children: [
|
AppStatusSummaryItem(
|
||||||
for (int i = 0; i < entries.length; i++) ...[
|
label: e.key.replaceAll('_', ' '),
|
||||||
if (i > 0) const SizedBox(width: 8),
|
count: e.value,
|
||||||
_StatusSummaryCard(
|
kind: _taskStatusKind(e.key),
|
||||||
status: entries[i].key,
|
),
|
||||||
count: entries[i].value,
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusSummaryCard extends StatelessWidget {
|
|
||||||
const _StatusSummaryCard({required this.status, required this.count});
|
|
||||||
|
|
||||||
final String status;
|
|
||||||
final int count;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
|
|
||||||
final background = swatch.container;
|
|
||||||
final foreground = swatch.onContainer;
|
|
||||||
final label = status.replaceAll('_', ' ').toUpperCase();
|
|
||||||
|
|
||||||
return Material(
|
|
||||||
color: background,
|
|
||||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: foreground,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
count.toString(),
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: foreground,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import '../../utils/snackbar.dart';
|
|||||||
import '../../widgets/app_breakpoints.dart';
|
import '../../widgets/app_breakpoints.dart';
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/app_state_view.dart';
|
import '../../widgets/app_state_view.dart';
|
||||||
|
import '../../widgets/app_status_summary.dart';
|
||||||
import '../../widgets/sync_pending_badge.dart';
|
import '../../widgets/sync_pending_badge.dart';
|
||||||
|
|
||||||
class TicketsListScreen extends ConsumerStatefulWidget {
|
class TicketsListScreen extends ConsumerStatefulWidget {
|
||||||
@@ -715,63 +716,15 @@ class _StatusSummaryRow extends StatelessWidget {
|
|||||||
final entries = counts.entries.toList()
|
final entries = counts.entries.toList()
|
||||||
..sort((a, b) => a.key.compareTo(b.key));
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return AppStatusSummaryRow(
|
||||||
scrollDirection: Axis.horizontal,
|
items: [
|
||||||
child: Row(
|
for (final e in entries)
|
||||||
children: [
|
AppStatusSummaryItem(
|
||||||
for (int i = 0; i < entries.length; i++) ...[
|
label: e.key.replaceAll('_', ' '),
|
||||||
if (i > 0) const SizedBox(width: 8),
|
count: e.value,
|
||||||
_StatusSummaryCard(
|
kind: _ticketStatusKind(e.key),
|
||||||
status: entries[i].key,
|
),
|
||||||
count: entries[i].value,
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusSummaryCard extends StatelessWidget {
|
|
||||||
const _StatusSummaryCard({required this.status, required this.count});
|
|
||||||
|
|
||||||
final String status;
|
|
||||||
final int count;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
|
|
||||||
final background = swatch.container;
|
|
||||||
final foreground = swatch.onContainer;
|
|
||||||
final label = status.replaceAll('_', ' ').toUpperCase();
|
|
||||||
|
|
||||||
return Material(
|
|
||||||
color: background,
|
|
||||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
||||||
color: foreground,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
count.toString(),
|
|
||||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
|
||||||
color: foreground,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../../providers/chat_provider.dart';
|
|||||||
import '../../providers/ramadan_provider.dart';
|
import '../../providers/ramadan_provider.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
import '../../widgets/app_page_header.dart';
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_section_header.dart';
|
||||||
import '../../widgets/app_state_view.dart';
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
@@ -126,22 +127,15 @@ class _SchedulePanel extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
child: Row(
|
child: AppSectionHeader(
|
||||||
children: [
|
'Duty Schedules',
|
||||||
Text(
|
padding: EdgeInsets.zero,
|
||||||
'Duty Schedules',
|
trailing: FilterChip(
|
||||||
style: Theme.of(
|
label: const Text('Show past'),
|
||||||
context,
|
selected: showPast,
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
onSelected: (v) =>
|
||||||
),
|
ref.read(showPastSchedulesProvider.notifier).state = v,
|
||||||
const Spacer(),
|
),
|
||||||
FilterChip(
|
|
||||||
label: const Text('Show past'),
|
|
||||||
selected: showPast,
|
|
||||||
onSelected: (v) =>
|
|
||||||
ref.read(showPastSchedulesProvider.notifier).state = v,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|||||||
@@ -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