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:
2026-09-26 15:13:47 +08:00
parent 3cb980b629
commit 57377b1b21
28 changed files with 986 additions and 470 deletions
+120 -56
View File
@@ -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;