UI: Cyber/IT design overhaul with semantic status tokens (Material 3)
Re-theme TasQ with a refined enterprise "Cyber/IT" identity on Material 3. Functionality is unchanged — only color/shape tokens and a few state widgets. Foundation: - Hand-tuned light + dark ColorSchemes (deep-slate surfaces, azure/teal/violet accents), tightened 12-16dp shape language, flat hairline-outlined cards. - New AppStatusColors theme extension (success/warning/danger/info/neutral, per-brightness) replacing scattered raw Colors.green/orange/red/grey so status color is consistent and legible in dark mode. - New TechChip mono primitive; StatusPill.kind; M3Card hairline variants; tightened AppSurfaces radii. Per-screen: - Tokenized status colors + radii across dashboard, tickets, tasks, IT service requests, attendance, whereabouts, network map, reports charts, auth, and shared/admin screens. Added _ticketStatusKind/_taskStatusKind/_isrStatusKind mappers so status carries meaning consistently. - Fixed white pie-slice borders for dark mode; dropped ad-hoc dialog radius overrides in favor of the theme default. Categorical palettes (leave-type icons, team color picker, distribution charts) intentionally preserved. Verified: flutter analyze lib clean; flutter build web green; visual QA in light and dark (dashboard, tickets, attendance). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -38,6 +38,7 @@ import '../../widgets/app_breakpoints.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
@@ -776,14 +777,29 @@ 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: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -918,21 +934,26 @@ class _MetricCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -967,14 +988,16 @@ class _StaffTable extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
// Cyber/IT panel: flat tonal fill + hairline outline.
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1059,18 +1082,23 @@ class _StaffTableBody extends ConsumerWidget {
|
||||
);
|
||||
|
||||
if (providerState == 'loading') {
|
||||
return const Text('Loading staff...');
|
||||
return _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
|
||||
}
|
||||
|
||||
if (providerState.startsWith('error:')) {
|
||||
final err = providerState.substring(6);
|
||||
return Text('Failed to load staff: $err');
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.error_outline_rounded,
|
||||
'Failed to load staff: $err',
|
||||
);
|
||||
}
|
||||
|
||||
if (staffRows.isEmpty) {
|
||||
return Text(
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.groups_outlined,
|
||||
'No IT staff available.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,20 +1170,24 @@ class _StaffRow extends StatelessWidget {
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Center(
|
||||
child: Text(
|
||||
row.whereabouts,
|
||||
style: valueStyle?.copyWith(
|
||||
color: row.whereabouts == 'In premise'
|
||||
? Colors.green
|
||||
: row.whereabouts == 'Outside premise'
|
||||
? Colors.grey
|
||||
: row.whereabouts == 'Tracking off'
|
||||
? Colors.grey
|
||||
: row.whereabouts.startsWith('Last seen')
|
||||
? Colors.grey
|
||||
: null,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final status = AppStatusColors.of(context);
|
||||
final Color? color = row.whereabouts == 'In premise'
|
||||
? status.success.color
|
||||
: (row.whereabouts == 'Outside premise' ||
|
||||
row.whereabouts == 'Tracking off' ||
|
||||
row.whereabouts.startsWith('Last seen'))
|
||||
? status.neutral.color
|
||||
: null;
|
||||
return Text(
|
||||
row.whereabouts,
|
||||
style: valueStyle?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1202,21 +1234,24 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: metricsAsync.when(
|
||||
data: (metrics) {
|
||||
final staffRows = metrics.staffRows;
|
||||
if (staffRows.isEmpty) {
|
||||
return Text(
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.bar_chart_outlined,
|
||||
'No staff activity data available.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1393,16 +1428,16 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(
|
||||
(0.35 * 255).round(),
|
||||
color: cs.inverseSurface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).chipRadius,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
child: MonoText(
|
||||
total.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
color: cs.onInverseSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
@@ -1534,8 +1569,13 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text('Failed to load activity: $error'),
|
||||
loading: () =>
|
||||
_panelHint(context, Icons.hourglass_empty_rounded, 'Loading activity…'),
|
||||
error: (error, stack) => _panelHint(
|
||||
context,
|
||||
Icons.error_outline_rounded,
|
||||
'Failed to load activity: $error',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1556,29 +1596,28 @@ class _PulseStatusPill extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final s = AppStatusColors.of(context);
|
||||
final (Color bg, Color fg) = switch (label.toLowerCase()) {
|
||||
'arrival' => (Colors.amber.shade100, Colors.amber.shade900),
|
||||
'late' => (Colors.red.shade100, Colors.red.shade900),
|
||||
'noon break' => (Colors.blue.shade100, Colors.blue.shade900),
|
||||
'vacant' => (Colors.green.shade100, Colors.green.shade900),
|
||||
'on task' => (Colors.purple.shade100, Colors.purple.shade900),
|
||||
'on event' => (Colors.purple.shade100, Colors.purple.shade900),
|
||||
'in triage' => (Colors.orange.shade100, Colors.orange.shade900),
|
||||
'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900),
|
||||
'on leave' => (Colors.teal.shade100, Colors.teal.shade900),
|
||||
'absent' => (Colors.red.shade200, Colors.red.shade900),
|
||||
'off duty' => (Colors.grey.shade200, Colors.grey.shade700),
|
||||
_ => (
|
||||
Theme.of(context).colorScheme.tertiaryContainer,
|
||||
Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
'arrival' => (s.success.container, s.success.onContainer),
|
||||
'late' => (s.warning.container, s.warning.onContainer),
|
||||
'noon break' => (s.info.container, s.info.onContainer),
|
||||
'vacant' => (s.neutral.container, s.neutral.onContainer),
|
||||
'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
|
||||
'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
'in triage' => (s.warning.container, s.warning.onContainer),
|
||||
'early out' => (s.warning.container, s.warning.onContainer),
|
||||
'on leave' => (s.info.container, s.info.onContainer),
|
||||
'absent' => (s.danger.container, s.danger.onContainer),
|
||||
'off duty' => (s.neutral.container, s.neutral.onContainer),
|
||||
_ => (cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -1591,6 +1630,31 @@ class _PulseStatusPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact, muted, centered hint for empty/loading/error states inside a
|
||||
/// bounded dashboard panel (too small for the full-screen AppEmptyView).
|
||||
Widget _panelHint(BuildContext context, IconData icon, String message) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Duration? _averageDuration(List<Duration> durations) {
|
||||
if (durations.isEmpty) {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user