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
+30 -19
View File
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@@ -111,8 +113,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
.check_circle .check_circle
: Icons.cancel, : Icons.cancel,
color: inside color: inside
? Colors.green ? AppStatusColors.of(context).success.color
: Colors.red, : AppStatusColors.of(context).danger.color,
), ),
const SizedBox( const SizedBox(
width: 8, width: 8,
@@ -166,8 +168,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
? Icons.check_circle ? Icons.check_circle
: Icons.cancel, : Icons.cancel,
color: inside color: inside
? Colors.green ? AppStatusColors.of(context).success.color
: Colors.red, : AppStatusColors.of(context).danger.color,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
@@ -275,10 +277,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 40, width: 40,
height: 40, height: 40,
// `child` is used by newer flutter_map Marker API. // `child` is used by newer flutter_map Marker API.
child: const Icon( child: Icon(
Icons.person_pin_circle, Icons.person_pin_circle,
size: 36, size: 36,
color: Colors.blue, color: Theme.of(context).colorScheme.primary,
), ),
), ),
); );
@@ -289,10 +291,12 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
polygons: [ polygons: [
Polygon( Polygon(
points: polygonPoints, points: polygonPoints,
color: Colors.green.withValues( color: AppStatusColors.of(context)
alpha: 0.15, .success
), .color
borderColor: Colors.green, .withValues(alpha: 0.15),
borderColor:
AppStatusColors.of(context).success.color,
borderStrokeWidth: 2, borderStrokeWidth: 2,
), ),
], ],
@@ -304,9 +308,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
circles: [ circles: [
CircleMarker( CircleMarker(
point: LatLng(cfg!.lat!, cfg.lng!), point: LatLng(cfg!.lat!, cfg.lng!),
color: Colors.green.withValues( color: AppStatusColors.of(context)
alpha: 0.10, .success
), .color
.withValues(alpha: 0.10),
borderStrokeWidth: 2, borderStrokeWidth: 2,
useRadiusInMeter: true, useRadiusInMeter: true,
radius: cfg.radiusMeters ?? 100, radius: cfg.radiusMeters ?? 100,
@@ -387,11 +392,15 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
width: 12, width: 12,
height: 12, height: 12,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.withValues( color: AppStatusColors.of(
context,
).success.color.withValues(
alpha: 0.25, alpha: 0.25,
), ),
border: Border.all( border: Border.all(
color: Colors.green, color: AppStatusColors.of(
context,
).success.color,
), ),
), ),
), ),
@@ -420,14 +429,16 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
top: 6.0, top: 6.0,
), ),
child: Row( child: Row(
children: const [ children: [
Icon( Icon(
Icons.person_pin_circle, Icons.person_pin_circle,
size: 14, size: 14,
color: Colors.blue, color: Theme.of(
context,
).colorScheme.primary,
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Text('You'), const Text('You'),
], ],
), ),
), ),
+24 -22
View File
@@ -35,6 +35,7 @@ import 'logbook_day_activity.dart';
import 'work_log_tab.dart'; import 'work_log_tab.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/device_security.dart'; import '../../utils/device_security.dart';
import '../../utils/location_permission.dart'; import '../../utils/location_permission.dart';
@@ -787,7 +788,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
} else if (diff.inMinutes <= 5) { } else if (diff.inMinutes <= 5) {
timeColor = colors.error; timeColor = colors.error;
} else if (diff.inMinutes <= 15) { } else if (diff.inMinutes <= 15) {
timeColor = Colors.orange; timeColor = AppStatusColors.of(context).warning.color;
} else if (diff.inMinutes <= 30) { } else if (diff.inMinutes <= 30) {
timeColor = colors.tertiary; timeColor = colors.tertiary;
} }
@@ -847,7 +848,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
: Icons.cancel, : Icons.cancel,
size: 16, size: 16,
color: _insideGeofence || hasGeofenceOverride color: _insideGeofence || hasGeofenceOverride
? Colors.green ? AppStatusColors.of(context).success.color
: colors.error, : colors.error,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@@ -859,7 +860,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
: 'Outside geofence', : 'Outside geofence',
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: _insideGeofence || hasGeofenceOverride color: _insideGeofence || hasGeofenceOverride
? Colors.green ? AppStatusColors.of(context).success.color
: colors.error, : colors.error,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -1863,11 +1864,11 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
bg = colors.secondaryContainer; bg = colors.secondaryContainer;
fg = colors.onSecondaryContainer; fg = colors.onSecondaryContainer;
case 'Early Out': case 'Early Out':
bg = Colors.orange.shade100; bg = AppStatusColors.of(context).warning.container;
fg = Colors.orange.shade900; fg = AppStatusColors.of(context).warning.onContainer;
case 'Noon Break': case 'Noon Break':
bg = Colors.blue.shade100; bg = AppStatusColors.of(context).info.container;
fg = Colors.blue.shade900; fg = AppStatusColors.of(context).info.onContainer;
case 'Absent': case 'Absent':
bg = colors.errorContainer; bg = colors.errorContainer;
fg = colors.onErrorContainer; fg = colors.onErrorContainer;
@@ -2841,13 +2842,14 @@ class _DateGroupTile extends StatelessWidget {
final colors = Theme.of(context).colorScheme; final colors = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
final status = AppStatusColors.of(context);
final statusColor = group.status == 'Absent' final statusColor = group.status == 'Absent'
? Colors.red ? status.danger.color
: group.status == 'On duty' : group.status == 'On duty'
? Colors.orange ? status.warning.color
: group.status == 'On Leave' : group.status == 'On Leave'
? Colors.teal ? status.info.color
: Colors.green; : status.success.color;
return Card( return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0), margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
@@ -2960,10 +2962,10 @@ class _DateGroupTile extends StatelessWidget {
String statusLabel; String statusLabel;
switch (slip.status) { switch (slip.status) {
case 'approved': case 'approved':
statusColor = Colors.green; statusColor = AppStatusColors.of(context).success.color;
statusLabel = 'Approved'; statusLabel = 'Approved';
case 'pending': case 'pending':
statusColor = Colors.orange; statusColor = AppStatusColors.of(context).warning.color;
statusLabel = 'Pending'; statusLabel = 'Pending';
case 'rejected': case 'rejected':
statusColor = colors.error; statusColor = colors.error;
@@ -3081,7 +3083,7 @@ class _DateGroupTile extends StatelessWidget {
? Chip( ? Chip(
label: Text(entry.isLeave ? 'On Leave' : 'Absent'), label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
backgroundColor: entry.isLeave backgroundColor: entry.isLeave
? Colors.teal.withValues(alpha: 0.15) ? AppStatusColors.of(context).info.container
: colors.errorContainer, : colors.errorContainer,
) )
: entry.status == 'On duty' : entry.status == 'On duty'
@@ -3107,7 +3109,7 @@ class _DateGroupTile extends StatelessWidget {
switch (entry.verificationStatus) { switch (entry.verificationStatus) {
case 'verified': case 'verified':
icon = Icons.verified; icon = Icons.verified;
color = Colors.green; color = AppStatusColors.of(context).success.color;
tooltip = 'Verified'; tooltip = 'Verified';
case 'unverified' || 'skipped': case 'unverified' || 'skipped':
icon = Icons.warning_amber_rounded; icon = Icons.warning_amber_rounded;
@@ -3115,7 +3117,7 @@ class _DateGroupTile extends StatelessWidget {
tooltip = 'Unverified'; tooltip = 'Unverified';
default: default:
icon = Icons.hourglass_bottom; icon = Icons.hourglass_bottom;
color = Colors.orange; color = AppStatusColors.of(context).warning.color;
tooltip = 'Pending'; tooltip = 'Pending';
} }
final badge = Tooltip( final badge = Tooltip(
@@ -3526,7 +3528,7 @@ class _CheckInOutMapTab extends StatelessWidget {
title: 'Check In', title: 'Check In',
lat: entry.checkInLat, lat: entry.checkInLat,
lng: entry.checkInLng, lng: entry.checkInLng,
markerColor: Colors.green, markerColor: AppStatusColors.of(context).success.color,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -3536,7 +3538,7 @@ class _CheckInOutMapTab extends StatelessWidget {
title: 'Check Out', title: 'Check Out',
lat: entry.checkOutLat, lat: entry.checkOutLat,
lng: entry.checkOutLng, lng: entry.checkOutLng,
markerColor: Colors.red, markerColor: AppStatusColors.of(context).danger.color,
), ),
), ),
], ],
@@ -5830,7 +5832,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
IconData statusIcon; IconData statusIcon;
switch (slip.status) { switch (slip.status) {
case 'approved': case 'approved':
statusColor = Colors.green; statusColor = AppStatusColors.of(context).success.color;
statusIcon = Icons.check_circle; statusIcon = Icons.check_circle;
case 'rejected': case 'rejected':
statusColor = colors.error; statusColor = colors.error;
@@ -5839,7 +5841,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
statusColor = colors.primary; statusColor = colors.primary;
statusIcon = Icons.task_alt; statusIcon = Icons.task_alt;
default: default:
statusColor = Colors.orange; statusColor = AppStatusColors.of(context).warning.color;
statusIcon = Icons.hourglass_top; statusIcon = Icons.hourglass_top;
} }
@@ -6248,7 +6250,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
IconData statusIcon; IconData statusIcon;
switch (leave.status) { switch (leave.status) {
case 'approved': case 'approved':
statusColor = Colors.teal; statusColor = AppStatusColors.of(context).success.color;
statusIcon = Icons.check_circle; statusIcon = Icons.check_circle;
case 'rejected': case 'rejected':
statusColor = colors.error; statusColor = colors.error;
@@ -6257,7 +6259,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
statusColor = colors.onSurfaceVariant; statusColor = colors.onSurfaceVariant;
statusIcon = Icons.remove_circle_outline; statusIcon = Icons.remove_circle_outline;
default: default:
statusColor = Colors.orange; statusColor = AppStatusColors.of(context).warning.color;
statusIcon = Icons.hourglass_top; statusIcon = Icons.hourglass_top;
} }
@@ -7,6 +7,7 @@ import '../../models/it_service_request.model.dart';
import '../../models/pass_slip.model.dart'; import '../../models/pass_slip.model.dart';
import '../../models/task.model.dart'; import '../../models/task.model.dart';
import '../../models/ticket.model.dart'; import '../../models/ticket.model.dart';
import '../../theme/app_surfaces.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
@@ -427,7 +428,7 @@ class _SummaryChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+7 -5
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
@@ -563,23 +564,24 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
} }
final normalized = (score / 6).clamp(0.0, 1.0); final normalized = (score / 6).clamp(0.0, 1.0);
final s = AppStatusColors.of(context);
String label; String label;
Color color; Color color;
if (normalized <= 0.2) { if (normalized <= 0.2) {
label = 'Very weak'; label = 'Very weak';
color = Colors.red; color = s.danger.color;
} else if (normalized <= 0.4) { } else if (normalized <= 0.4) {
label = 'Weak'; label = 'Weak';
color = Colors.deepOrange; color = s.danger.color;
} else if (normalized <= 0.6) { } else if (normalized <= 0.6) {
label = 'Fair'; label = 'Fair';
color = Colors.orange; color = s.warning.color;
} else if (normalized <= 0.8) { } else if (normalized <= 0.8) {
label = 'Strong'; label = 'Strong';
color = Colors.green; color = s.info.color;
} else { } else {
label = 'Excellent'; label = 'Excellent';
color = Colors.teal; color = s.success.color;
} }
setState(() { setState(() {
+120 -56
View File
@@ -38,6 +38,7 @@ import '../../widgets/app_breakpoints.dart';
import '../../providers/realtime_controller.dart'; import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
@@ -776,14 +777,29 @@ class _DashboardScreenState extends State<DashboardScreen> {
} }
Widget _sectionTitle(BuildContext context, String title) { Widget _sectionTitle(BuildContext context, String title) {
final cs = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Text( child: Row(
title, children: [
style: Theme.of(context).textTheme.titleMedium?.copyWith( Container(
fontWeight: FontWeight.w700, width: 3,
color: Theme.of(context).colorScheme.onSurfaceVariant, 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( return AnimatedContainer(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).cardRadius,
),
border: Border.all(color: cs.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
title, title.toUpperCase(),
style: Theme.of(context).textTheme.labelLarge?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.6,
color: cs.onSurfaceVariant, color: cs.onSurfaceVariant,
), ),
), ),
@@ -967,14 +988,16 @@ class _StaffTable extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; 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( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius, AppSurfaces.of(context).containerRadius,
), ),
border: Border.all(color: cs.outlineVariant),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -1059,18 +1082,23 @@ class _StaffTableBody extends ConsumerWidget {
); );
if (providerState == 'loading') { if (providerState == 'loading') {
return const Text('Loading staff...'); return _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
} }
if (providerState.startsWith('error:')) { if (providerState.startsWith('error:')) {
final err = providerState.substring(6); 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) { if (staffRows.isEmpty) {
return Text( return _panelHint(
context,
Icons.groups_outlined,
'No IT staff available.', 'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
); );
} }
@@ -1142,20 +1170,24 @@ class _StaffRow extends StatelessWidget {
Expanded( Expanded(
flex: 4, flex: 4,
child: Center( child: Center(
child: Text( child: Builder(
row.whereabouts, builder: (context) {
style: valueStyle?.copyWith( final status = AppStatusColors.of(context);
color: row.whereabouts == 'In premise' final Color? color = row.whereabouts == 'In premise'
? Colors.green ? status.success.color
: row.whereabouts == 'Outside premise' : (row.whereabouts == 'Outside premise' ||
? Colors.grey row.whereabouts == 'Tracking off' ||
: row.whereabouts == 'Tracking off' row.whereabouts.startsWith('Last seen'))
? Colors.grey ? status.neutral.color
: row.whereabouts.startsWith('Last seen') : null;
? Colors.grey return Text(
: null, row.whereabouts,
fontWeight: FontWeight.w600, style: valueStyle?.copyWith(
), color: color,
fontWeight: FontWeight.w600,
),
);
},
), ),
), ),
), ),
@@ -1202,21 +1234,24 @@ class _StaffActivityChart extends ConsumerWidget {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final isDark = cs.brightness == Brightness.dark;
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.surfaceContainerLow, color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius, AppSurfaces.of(context).containerRadius,
), ),
border: Border.all(color: cs.outlineVariant),
), ),
child: metricsAsync.when( child: metricsAsync.when(
data: (metrics) { data: (metrics) {
final staffRows = metrics.staffRows; final staffRows = metrics.staffRows;
if (staffRows.isEmpty) { if (staffRows.isEmpty) {
return Text( return _panelHint(
context,
Icons.bar_chart_outlined,
'No staff activity data available.', 'No staff activity data available.',
style: Theme.of(context).textTheme.bodySmall,
); );
} }
@@ -1393,16 +1428,16 @@ class _StaffActivityChart extends ConsumerWidget {
vertical: 2, vertical: 2,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withAlpha( color: cs.inverseSurface.withValues(alpha: 0.9),
(0.35 * 255).round(), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).chipRadius,
), ),
borderRadius: BorderRadius.circular(8),
), ),
child: Text( child: MonoText(
total.toString(), total.toString(),
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: Colors.white, color: cs.onInverseSurface,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
@@ -1534,8 +1569,13 @@ class _StaffActivityChart extends ConsumerWidget {
}, },
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () =>
error: (error, stack) => Text('Failed to load activity: $error'), _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final s = AppStatusColors.of(context);
final (Color bg, Color fg) = switch (label.toLowerCase()) { final (Color bg, Color fg) = switch (label.toLowerCase()) {
'arrival' => (Colors.amber.shade100, Colors.amber.shade900), 'arrival' => (s.success.container, s.success.onContainer),
'late' => (Colors.red.shade100, Colors.red.shade900), 'late' => (s.warning.container, s.warning.onContainer),
'noon break' => (Colors.blue.shade100, Colors.blue.shade900), 'noon break' => (s.info.container, s.info.onContainer),
'vacant' => (Colors.green.shade100, Colors.green.shade900), 'vacant' => (s.neutral.container, s.neutral.onContainer),
'on task' => (Colors.purple.shade100, Colors.purple.shade900), 'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
'on event' => (Colors.purple.shade100, Colors.purple.shade900), 'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
'in triage' => (Colors.orange.shade100, Colors.orange.shade900), 'in triage' => (s.warning.container, s.warning.onContainer),
'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900), 'early out' => (s.warning.container, s.warning.onContainer),
'on leave' => (Colors.teal.shade100, Colors.teal.shade900), 'on leave' => (s.info.container, s.info.onContainer),
'absent' => (Colors.red.shade200, Colors.red.shade900), 'absent' => (s.danger.container, s.danger.onContainer),
'off duty' => (Colors.grey.shade200, Colors.grey.shade700), 'off duty' => (s.neutral.container, s.neutral.onContainer),
_ => ( _ => (cs.tertiaryContainer, cs.onTertiaryContainer),
Theme.of(context).colorScheme.tertiaryContainer,
Theme.of(context).colorScheme.onTertiaryContainer,
),
}; };
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Text( child: Text(
label, 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) { Duration? _averageDuration(List<Duration> durations) {
if (durations.isEmpty) { if (durations.isEmpty) {
return null; return null;
@@ -29,6 +29,7 @@ import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
import 'it_service_request_pdf.dart'; import 'it_service_request_pdf.dart';
@@ -411,13 +412,13 @@ class _ItServiceRequestDetailScreenState
), ),
) )
: _hasRecentSave : _hasRecentSave
? const Padding( ? Padding(
key: ValueKey('isr_saved'), key: const ValueKey('isr_saved'),
padding: EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon( child: Icon(
Icons.check_circle, Icons.check_circle,
size: 18, size: 18,
color: Colors.green, color: AppStatusColors.of(context).success.color,
), ),
) )
: const SizedBox(key: ValueKey('isr_idle')), : const SizedBox(key: ValueKey('isr_idle')),
@@ -555,7 +556,10 @@ class _ItServiceRequestDetailScreenState
if (request.requestNumber != null) if (request.requestNumber != null)
MonoText(request.requestNumber!), MonoText(request.requestNumber!),
const SizedBox(width: 12), const SizedBox(width: 12),
StatusPill(label: ItServiceRequestStatus.label(request.status)), StatusPill.kind(
_isrStatusKind(request.status),
label: ItServiceRequestStatus.label(request.status),
),
const Spacer(), const Spacer(),
Text( Text(
'Created ${AppTime.formatDate(request.createdAt)}', 'Created ${AppTime.formatDate(request.createdAt)}',
@@ -1231,10 +1235,10 @@ class _ItServiceRequestDetailScreenState
child: const Icon(Icons.save, size: 14), child: const Icon(Icons.save, size: 14),
) )
: isSaved : isSaved
? const Icon( ? Icon(
Icons.check_circle, Icons.check_circle,
key: ValueKey('label_saved'), key: const ValueKey('label_saved'),
color: Colors.green, color: AppStatusColors.of(context).success.color,
size: 14, size: 14,
) )
: const SizedBox(key: ValueKey('label_idle')), : const SizedBox(key: ValueKey('label_idle')),
@@ -1584,9 +1588,6 @@ class _ItServiceRequestDetailScreenState
final controller = TextEditingController(); final controller = TextEditingController();
return AlertDialog( return AlertDialog(
title: const Text('Cancellation Reason'), title: const Text('Cancellation Reason'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: TextField( content: TextField(
controller: controller, controller: controller,
decoration: const InputDecoration( decoration: const InputDecoration(
@@ -1836,7 +1837,6 @@ class _StaffPickerDialogState extends State<_StaffPickerDialog> {
.toList(); .toList();
return AlertDialog( return AlertDialog(
title: const Text('Select IT Staff'), title: const Text('Select IT Staff'),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
content: SizedBox( content: SizedBox(
width: 350, width: 350,
height: 400, height: 400,
@@ -2245,3 +2245,16 @@ class _EvidenceSectionState extends ConsumerState<_EvidenceSection> {
); );
} }
} }
/// Maps an IT service request status to a semantic [StatusKind]
/// (amber=pending approval, blue=scheduled/in-progress, green=completed,
/// grey=cancelled).
StatusKind _isrStatusKind(String status) => switch (status) {
ItServiceRequestStatus.pendingApproval => StatusKind.warning,
ItServiceRequestStatus.scheduled => StatusKind.info,
ItServiceRequestStatus.inProgress => StatusKind.info,
ItServiceRequestStatus.inProgressDryRun => StatusKind.info,
ItServiceRequestStatus.completed => StatusKind.success,
ItServiceRequestStatus.cancelled => StatusKind.neutral,
_ => StatusKind.neutral,
};
@@ -17,6 +17,7 @@ import '../../providers/tickets_provider.dart';
import '../../theme/m3_motion.dart'; 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 '../../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';
@@ -381,9 +382,6 @@ class _ItServiceRequestsListScreenState
builder: (ctx, setDialogState) { builder: (ctx, setDialogState) {
return AlertDialog( return AlertDialog(
title: const Text('New IT Service Request'), title: const Text('New IT Service Request'),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28),
),
content: SizedBox( content: SizedBox(
width: 400, width: 400,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -538,6 +536,7 @@ class _StatusSummaryRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; 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;
@@ -563,7 +562,7 @@ class _StatusSummaryRow extends StatelessWidget {
_SummaryChip( _SummaryChip(
label: 'Pending', label: 'Pending',
count: pending, count: pending,
color: cs.tertiary, color: status.warning.color,
selected: selectedStatus == ItServiceRequestStatus.pendingApproval, selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval), onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
), ),
@@ -571,7 +570,7 @@ class _StatusSummaryRow extends StatelessWidget {
_SummaryChip( _SummaryChip(
label: 'Scheduled', label: 'Scheduled',
count: scheduled, count: scheduled,
color: cs.primary, color: status.info.color,
selected: selectedStatus == ItServiceRequestStatus.scheduled, selected: selectedStatus == ItServiceRequestStatus.scheduled,
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled), onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
), ),
@@ -587,7 +586,7 @@ class _StatusSummaryRow extends StatelessWidget {
_SummaryChip( _SummaryChip(
label: 'Completed', label: 'Completed',
count: completed, count: completed,
color: Colors.green, color: status.success.color,
selected: selectedStatus == ItServiceRequestStatus.completed, selected: selectedStatus == ItServiceRequestStatus.completed,
onTap: () => onStatusTap(ItServiceRequestStatus.completed), onTap: () => onStatusTap(ItServiceRequestStatus.completed),
), ),
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../../models/network/network_device.dart'; import '../../models/network/network_device.dart';
import '../../models/network/network_location.dart'; import '../../models/network/network_location.dart';
import '../../theme/app_status_colors.dart';
import '../../providers/network_map/network_devices_provider.dart'; 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 '../../widgets/app_state_view.dart'; import '../../widgets/app_state_view.dart';
@@ -231,11 +232,19 @@ class _NetworkMapDeviceEditScreenState
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
switch (_status) { switch (_status) {
case NetworkDeviceStatus.online: case NetworkDeviceStatus.online:
return const Icon(Icons.circle, size: 14, color: Color(0xFF388E3C)); return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).success.color,
);
case NetworkDeviceStatus.offline: case NetworkDeviceStatus.offline:
return Icon(Icons.circle, size: 14, color: cs.error); return Icon(Icons.circle, size: 14, color: cs.error);
case NetworkDeviceStatus.warning: case NetworkDeviceStatus.warning:
return const Icon(Icons.circle, size: 14, color: Color(0xFFF57C00)); return Icon(
Icons.circle,
size: 14,
color: AppStatusColors.of(context).warning.color,
);
case NetworkDeviceStatus.unknown: case NetworkDeviceStatus.unknown:
return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant); return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant);
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart'; import '../../../models/network/network_device.dart';
import '../../../theme/app_status_colors.dart';
import 'network_device_icon.dart'; import 'network_device_icon.dart';
/// A styled node representing one network device on the topology canvas. /// A styled node representing one network device on the topology canvas.
@@ -41,14 +42,15 @@ class DeviceNode extends StatelessWidget {
} }
} }
Color? _statusColor(ColorScheme cs, NetworkDeviceStatus status) { Color? _statusColor(BuildContext context, NetworkDeviceStatus status) {
final s = AppStatusColors.of(context);
switch (status) { switch (status) {
case NetworkDeviceStatus.online: case NetworkDeviceStatus.online:
return const Color(0xFF34A853); // Google green — universally understood return s.success.color;
case NetworkDeviceStatus.offline: case NetworkDeviceStatus.offline:
return cs.error; return Theme.of(context).colorScheme.error;
case NetworkDeviceStatus.warning: case NetworkDeviceStatus.warning:
return const Color(0xFFFBBC04); // Amber return s.warning.color;
case NetworkDeviceStatus.unknown: case NetworkDeviceStatus.unknown:
return null; // No dot shown return null; // No dot shown
} }
@@ -59,7 +61,7 @@ class DeviceNode extends StatelessWidget {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme; final tt = Theme.of(context).textTheme;
final accent = _accentForRole(cs, device.role); final accent = _accentForRole(cs, device.role);
final statusColor = _statusColor(cs, device.status); final statusColor = _statusColor(context, device.status);
final bgColor = isSelected final bgColor = isSelected
? cs.primaryContainer ? cs.primaryContainer
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../models/network/network_device.dart'; import '../../../models/network/network_device.dart';
import '../../../models/network/network_link.dart'; import '../../../models/network/network_link.dart';
import '../../../theme/app_surfaces.dart';
/// Collapsible legend overlay explaining device role border colours and link /// Collapsible legend overlay explaining device role border colours and link
/// type colours. Positioned at the bottom-left of the topology canvas view. /// type colours. Positioned at the bottom-left of the topology canvas view.
@@ -85,11 +86,15 @@ class _TopologyLegendState extends State<TopologyLegend>
// Expand/collapse toggle chip // Expand/collapse toggle chip
Material( Material(
color: cs.surfaceContainerHigh, color: cs.surfaceContainerHigh,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
elevation: 2, elevation: 2,
child: InkWell( child: InkWell(
onTap: _toggle, onTap: _toggle,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row( child: Row(
@@ -109,7 +109,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: colorFor(i, e.name), color: colorFor(i, e.name),
borderSide: isTouched borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2) ? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
@@ -240,7 +240,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: colorFor(i, e.name), color: colorFor(i, e.name),
borderSide: isTouched borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2) ? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
+18 -13
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/reports_provider.dart'; import '../../../providers/reports_provider.dart';
import '../../../theme/app_status_colors.dart';
import 'report_card_wrapper.dart'; import 'report_card_wrapper.dart';
/// Donut chart ticket counts per status with hover animation. /// Donut chart ticket counts per status with hover animation.
@@ -84,7 +85,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: _ticketStatusColor(context, e.status), color: _ticketStatusColor(context, e.status),
borderSide: isTouched borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2) ? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
@@ -116,16 +117,18 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
} }
Color _ticketStatusColor(BuildContext context, String status) { Color _ticketStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme; final s = AppStatusColors.of(context);
switch (status) { switch (status) {
case 'critical':
return s.danger.color;
case 'pending': case 'pending':
return colors.tertiary; return s.warning.color;
case 'promoted': case 'promoted':
return colors.secondary; return s.info.color;
case 'closed': case 'closed':
return colors.primary; return s.success.color;
default: default:
return colors.outlineVariant; return s.neutral.color;
} }
} }
} }
@@ -208,7 +211,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
radius: isTouched ? 60 : 50, radius: isTouched ? 60 : 50,
color: _taskStatusColor(context, e.status), color: _taskStatusColor(context, e.status),
borderSide: isTouched borderSide: isTouched
? const BorderSide(color: Colors.white, width: 2) ? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
: BorderSide.none, : BorderSide.none,
); );
}).toList(), }).toList(),
@@ -240,18 +243,20 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
} }
Color _taskStatusColor(BuildContext context, String status) { Color _taskStatusColor(BuildContext context, String status) {
final colors = Theme.of(context).colorScheme; final s = AppStatusColors.of(context);
switch (status) { switch (status) {
case 'critical':
return s.danger.color;
case 'queued': case 'queued':
return colors.surfaceContainerHighest; return s.neutral.color;
case 'in_progress': case 'in_progress':
return colors.secondary; return s.info.color;
case 'completed': case 'completed':
return colors.primary; return s.success.color;
case 'cancelled': case 'cancelled':
return colors.error; return s.danger.color;
default: default:
return colors.outlineVariant; return s.neutral.color;
} }
} }
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/verification_session_provider.dart'; import '../../providers/verification_session_provider.dart';
import '../../services/face_verification.dart' as face; import '../../services/face_verification.dart' as face;
import '../../theme/app_status_colors.dart';
/// Screen opened on mobile when the user scans a QR code for cross-device /// Screen opened on mobile when the user scans a QR code for cross-device
/// face verification. Performs liveness detection and uploads the result. /// face verification. Performs liveness detection and uploads the result.
@@ -144,7 +145,11 @@ class _MobileVerificationScreenState
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.check_circle, size: 64, color: Colors.green), Icon(
Icons.check_circle,
size: 64,
color: AppStatusColors.of(context).success.color,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text('Verification Complete', style: theme.textTheme.headlineSmall), Text('Verification Complete', style: theme.textTheme.headlineSmall),
const SizedBox(height: 8), const SizedBox(height: 8),
+5 -1
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/m3_motion.dart'; import '../../theme/m3_motion.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
@@ -67,7 +68,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
title: Text(info.label), title: Text(info.label),
subtitle: Text(status?.toString() ?? 'unknown'), subtitle: Text(status?.toString() ?? 'unknown'),
trailing: granted trailing: granted
? const Icon(Icons.check, color: Colors.green) ? Icon(
Icons.check,
color: AppStatusColors.of(context).success.color,
)
: Row( : Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
+21 -14
View File
@@ -13,6 +13,8 @@ import '../../providers/teams_provider.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
import '../../theme/app_status_colors.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/m3_card.dart'; import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/profile_avatar.dart'; import '../../widgets/profile_avatar.dart';
@@ -231,24 +233,25 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
} }
Widget _buildSkeletonTile(BuildContext context, bool isWide) { Widget _buildSkeletonTile(BuildContext context, bool isWide) {
final bone = Theme.of(context).colorScheme.surfaceContainerHighest;
return M3Card.outlined( return M3Card.outlined(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Row( child: Row(
children: [ children: [
Container(width: 60, height: 14, color: Colors.grey), Container(width: 60, height: 14, color: bone),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Container(height: 14, color: Colors.grey), child: Container(height: 14, color: bone),
), ),
if (isWide) ...[ if (isWide) ...[
const SizedBox(width: 12), const SizedBox(width: 12),
Container(width: 80, height: 22, color: Colors.grey), Container(width: 80, height: 22, color: bone),
const SizedBox(width: 12), const SizedBox(width: 12),
Container(width: 120, height: 14, color: Colors.grey), Container(width: 120, height: 14, color: bone),
], ],
const SizedBox(width: 12), const SizedBox(width: 12),
Container(width: 24, height: 24, color: Colors.grey), Container(width: 24, height: 24, color: bone),
], ],
), ),
), ),
@@ -292,20 +295,21 @@ class _StatsCard extends StatelessWidget {
), ),
); );
final status = AppStatusColors.of(context);
final submittedChip = _StatChip( final submittedChip = _StatChip(
icon: Icons.check_circle_outline, icon: Icons.check_circle_outline,
label: 'Submitted', label: 'Submitted',
count: submitted, count: submitted,
color: cs.primary, color: status.success.onContainer,
bgColor: cs.primaryContainer, bgColor: status.success.container,
); );
final pendingChip = _StatChip( final pendingChip = _StatChip(
icon: Icons.pending_outlined, icon: Icons.pending_outlined,
label: 'Pending', label: 'Pending',
count: pending, count: pending,
color: cs.tertiary, color: status.warning.onContainer,
bgColor: cs.tertiaryContainer, bgColor: status.warning.container,
); );
return M3Card.filled( return M3Card.filled(
@@ -399,7 +403,7 @@ class _StatChip extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -878,11 +882,14 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
_isChecked ? cs.primaryContainer.withValues(alpha: 0.18) : null; _isChecked ? cs.primaryContainer.withValues(alpha: 0.18) : null;
// Status badge // Status badge
final statusSwatch = _isChecked
? AppStatusColors.of(context).success
: AppStatusColors.of(context).warning;
final statusBadge = Container( final statusBadge = Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isChecked ? cs.primaryContainer : cs.tertiaryContainer, color: statusSwatch.container,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -892,13 +899,13 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
? Icons.check_circle_outline ? Icons.check_circle_outline
: Icons.hourglass_empty_outlined, : Icons.hourglass_empty_outlined,
size: 12, size: 12,
color: _isChecked ? cs.primary : cs.tertiary, color: statusSwatch.onContainer,
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
_isChecked ? 'Submitted' : 'Pending', _isChecked ? 'Submitted' : 'Pending',
style: tt.labelSmall?.copyWith( style: tt.labelSmall?.copyWith(
color: _isChecked ? cs.primary : cs.tertiary, color: statusSwatch.onContainer,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
+35 -38
View File
@@ -35,6 +35,7 @@ import '../../widgets/office_picker.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/sync_pending_badge.dart'; import '../../widgets/sync_pending_badge.dart';
import '../../widgets/task_assignment_section.dart'; import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
@@ -621,9 +622,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
const Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
color: Colors.orange, color: AppStatusColors.of(context).warning.color,
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
@@ -2686,16 +2687,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
final files = final files =
_attachments ?? []; _attachments ?? [];
if (files.isEmpty) { if (files.isEmpty) {
return const Center( return Center(
child: Padding( child: Padding(
padding: EdgeInsets.all( padding:
const EdgeInsets.all(
16.0, 16.0,
), ),
child: Text( child: Text(
'No attachments yet', 'No attachments yet',
style: TextStyle( style: Theme.of(context)
color: Colors.grey, .textTheme
), .bodySmall
?.copyWith(
color: Theme.of(
context,
)
.colorScheme
.onSurfaceVariant,
),
), ),
), ),
); );
@@ -2722,9 +2731,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
).textTheme.bodySmall, ).textTheme.bodySmall,
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon( icon: Icon(
Icons.delete, Icons.delete,
color: Colors.red, color: Theme.of(
context,
).colorScheme.error,
), ),
onPressed: () => onPressed: () =>
_deleteTaskAttachment( _deleteTaskAttachment(
@@ -2873,7 +2884,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.colorScheme .colorScheme
.surfaceContainerHighest, .surfaceContainerHighest,
borderRadius: borderRadius:
BorderRadius.circular(16), BorderRadius.circular(
AppSurfaces.of(
context,
).cardRadius,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -4883,39 +4898,21 @@ class _TaskStatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final s = AppStatusColors.of(context);
final (icon, bg, fg) = switch (status) { final (IconData icon, StatusSwatch swatch) = switch (status) {
'queued' => ( 'queued' => (Icons.inbox_rounded, s.neutral),
Icons.inbox_rounded, 'in_progress' => (Icons.play_arrow_rounded, s.info),
cs.secondaryContainer, 'completed' => (Icons.check_circle_rounded, s.success),
cs.onSecondaryContainer, 'cancelled' => (Icons.cancel_rounded, s.danger),
), _ => (Icons.help_outline_rounded, s.neutral),
'in_progress' => (
Icons.play_arrow_rounded,
cs.primaryContainer,
cs.onPrimaryContainer,
),
'completed' => (
Icons.check_circle_rounded,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
'cancelled' => (
Icons.cancel_rounded,
cs.errorContainer,
cs.onErrorContainer,
),
_ => (
Icons.help_outline_rounded,
cs.surfaceContainerHighest,
cs.onSurface,
),
}; };
final bg = swatch.container;
final fg = swatch.onContainer;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+22 -35
View File
@@ -29,6 +29,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; 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 '../../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';
@@ -523,10 +524,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
if (task.status == 'completed' && if (task.status == 'completed' &&
task.hasIncompleteDetails) ...[ task.hasIncompleteDetails) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
size: 16, size: 16,
color: Colors.orange, color: AppStatusColors.of(context).warning.color,
), ),
], ],
], ],
@@ -620,10 +621,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
if (task.status == 'completed' && if (task.status == 'completed' &&
task.hasIncompleteDetails) ...[ task.hasIncompleteDetails) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon( Icon(
Icons.warning_amber_rounded, Icons.warning_amber_rounded,
size: 16, size: 16,
color: Colors.orange, color: AppStatusColors.of(context).warning.color,
), ),
], ],
if (showTyping) ...[ if (showTyping) ...[
@@ -1353,26 +1354,14 @@ class _StatusSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
final background = switch (status) { final background = swatch.container;
'critical' => scheme.errorContainer, final foreground = swatch.onContainer;
'queued' => scheme.surfaceContainerHighest,
'in_progress' => scheme.secondaryContainer,
'completed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHigh,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'queued' => scheme.onSurfaceVariant,
'in_progress' => scheme.onSecondaryContainer,
'completed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase(); final label = status.replaceAll('_', ' ').toUpperCase();
return Material( return Material(
color: background, color: background,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row( child: Row(
@@ -1401,6 +1390,16 @@ class _StatusSummaryCard extends StatelessWidget {
} }
} }
/// Maps a task status to a semantic [StatusKind] (red=critical, blue=in
/// progress, green=completed, grey=queued).
StatusKind _taskStatusKind(String status) => switch (status) {
'critical' => StatusKind.danger,
'in_progress' => StatusKind.info,
'completed' => StatusKind.success,
'queued' => StatusKind.neutral,
_ => StatusKind.neutral,
};
String _assignedAgent(Map<String, Profile> profileById, String? userId) { String _assignedAgent(Map<String, Profile> profileById, String? userId) {
if (userId == null || userId.isEmpty) { if (userId == null || userId.isEmpty) {
return 'Unassigned'; return 'Unassigned';
@@ -1422,21 +1421,9 @@ class _StatusBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
final background = switch (status) { final background = swatch.container;
'critical' => scheme.errorContainer, final foreground = swatch.onContainer;
'queued' => scheme.surfaceContainerHighest,
'in_progress' => scheme.secondaryContainer,
'completed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHighest,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'queued' => scheme.onSurfaceVariant,
'in_progress' => scheme.onSecondaryContainer,
'completed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
return Badge( return Badge(
backgroundColor: background, backgroundColor: background,
+16 -25
View File
@@ -22,6 +22,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/task_assignment_section.dart'; import '../../widgets/task_assignment_section.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';
class TicketDetailScreen extends ConsumerStatefulWidget { class TicketDetailScreen extends ConsumerStatefulWidget {
const TicketDetailScreen({super.key, required this.ticketId}); const TicketDetailScreen({super.key, required this.ticketId});
@@ -424,7 +425,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.surfaceContainerHighest, ).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).cardRadius,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -467,7 +470,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceContainerHighest, .surfaceContainerHighest,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(
AppSurfaces.of(context).containerRadius,
),
), ),
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: 16, left: 16,
@@ -1357,34 +1362,20 @@ class _TicketStatusChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final s = AppStatusColors.of(context);
final (icon, bg, fg) = switch (status) { final (IconData icon, StatusSwatch swatch) = switch (status) {
'pending' => ( 'pending' => (Icons.hourglass_empty_rounded, s.warning),
Icons.hourglass_empty_rounded, 'promoted' => (Icons.trending_up_rounded, s.info),
cs.secondaryContainer, 'closed' => (Icons.lock_rounded, s.success),
cs.onSecondaryContainer, _ => (Icons.help_outline_rounded, s.neutral),
),
'promoted' => (
Icons.trending_up_rounded,
cs.primaryContainer,
cs.onPrimaryContainer,
),
'closed' => (
Icons.lock_rounded,
cs.tertiaryContainer,
cs.onTertiaryContainer,
),
_ => (
Icons.help_outline_rounded,
cs.surfaceContainerHighest,
cs.onSurface,
),
}; };
final bg = swatch.container;
final fg = swatch.onContainer;
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
+18 -31
View File
@@ -23,6 +23,7 @@ import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; 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 '../../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';
@@ -739,26 +740,14 @@ class _StatusSummaryCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
final background = switch (status) { final background = swatch.container;
'critical' => scheme.errorContainer, final foreground = swatch.onContainer;
'pending' => scheme.tertiaryContainer,
'promoted' => scheme.secondaryContainer,
'closed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHigh,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'pending' => scheme.onTertiaryContainer,
'promoted' => scheme.onSecondaryContainer,
'closed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
final label = status.replaceAll('_', ' ').toUpperCase(); final label = status.replaceAll('_', ' ').toUpperCase();
return Material( return Material(
color: background, color: background,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Row( child: Row(
@@ -787,6 +776,16 @@ class _StatusSummaryCard extends StatelessWidget {
} }
} }
/// Maps a ticket status to a semantic [StatusKind] so its color carries meaning
/// (red=critical, amber=pending, blue=promoted/escalated, green=closed).
StatusKind _ticketStatusKind(String status) => switch (status) {
'critical' => StatusKind.danger,
'pending' => StatusKind.warning,
'promoted' => StatusKind.info,
'closed' => StatusKind.success,
_ => StatusKind.neutral,
};
/// First 8 chars of the UUID, like a short git hash; the full id is in the /// First 8 chars of the UUID, like a short git hash; the full id is in the
/// tooltip. Tickets have no human-friendly number. /// tooltip. Tickets have no human-friendly number.
String _shortId(String id) => String _shortId(String id) =>
@@ -813,21 +812,9 @@ class _StatusBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
final background = switch (status) { final background = swatch.container;
'critical' => scheme.errorContainer, final foreground = swatch.onContainer;
'pending' => scheme.tertiaryContainer,
'promoted' => scheme.secondaryContainer,
'closed' => scheme.primaryContainer,
_ => scheme.surfaceContainerHighest,
};
final foreground = switch (status) {
'critical' => scheme.onErrorContainer,
'pending' => scheme.onTertiaryContainer,
'promoted' => scheme.onSecondaryContainer,
'closed' => scheme.onPrimaryContainer,
_ => scheme.onSurfaceVariant,
};
return Badge( return Badge(
backgroundColor: background, backgroundColor: background,
+26 -13
View File
@@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
import '../../providers/whereabouts_provider.dart'; import '../../providers/whereabouts_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../theme/app_status_colors.dart';
import '../../widgets/app_page_header.dart'; import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
@@ -232,8 +233,10 @@ class _WhereaboutsMap extends StatelessWidget {
polygons: [ polygons: [
Polygon( Polygon(
points: points, points: points,
color: Colors.blue.withValues(alpha: 0.1), color: Theme.of(
borderColor: Colors.blue, context,
).colorScheme.primary.withValues(alpha: 0.12),
borderColor: Theme.of(context).colorScheme.primary,
borderStrokeWidth: 2.0, borderStrokeWidth: 2.0,
), ),
], ],
@@ -313,11 +316,20 @@ class _StaffLegendPanel extends StatelessWidget {
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
), ),
const Spacer(), const Spacer(),
_RoleBadge(color: Colors.blue.shade700, label: 'Admin'), _RoleBadge(
color: Theme.of(context).colorScheme.primary,
label: 'Admin',
),
const SizedBox(width: 12), const SizedBox(width: 12),
_RoleBadge(color: Colors.green.shade700, label: 'IT Staff'), _RoleBadge(
color: Theme.of(context).colorScheme.secondary,
label: 'IT Staff',
),
const SizedBox(width: 12), const SizedBox(width: 12),
_RoleBadge(color: Colors.orange.shade700, label: 'Dispatcher'), _RoleBadge(
color: Theme.of(context).colorScheme.tertiary,
label: 'Dispatcher',
),
], ],
), ),
), ),
@@ -416,6 +428,7 @@ class _StaffLegendTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
final roleColor = _roleColor(profile.role, cs); final roleColor = _roleColor(profile.role, cs);
final hasPosition = position != null; final hasPosition = position != null;
@@ -445,30 +458,30 @@ class _StaffLegendTile extends StatelessWidget {
? 'In premise (Tracking off) \u00b7 $timeLabel' ? 'In premise (Tracking off) \u00b7 $timeLabel'
: 'Outside premise (Tracking off) \u00b7 $timeLabel'; : 'Outside premise (Tracking off) \u00b7 $timeLabel';
statusColor = isStale statusColor = isStale
? Colors.grey ? status.neutral.color
: (isInPremise ? Colors.green : Colors.grey); : (isInPremise ? status.success.color : status.neutral.color);
} else if (inferredInPremise) { } else if (inferredInPremise) {
statusText = 'In premise (Checked in)'; statusText = 'In premise (Checked in)';
statusColor = Colors.green; statusColor = status.success.color;
} else { } else {
statusText = 'Tracking off'; statusText = 'Tracking off';
statusColor = Colors.grey; statusColor = status.neutral.color;
} }
} else if (isStale && hasPosition) { } else if (isStale && hasPosition) {
final timeLabel = _timeAgo(position!.updatedAt); final timeLabel = _timeAgo(position!.updatedAt);
statusText = isInPremise statusText = isInPremise
? 'Last seen in premise \u00b7 $timeLabel' ? 'Last seen in premise \u00b7 $timeLabel'
: 'Last seen outside premise \u00b7 $timeLabel'; : 'Last seen outside premise \u00b7 $timeLabel';
statusColor = Colors.grey; statusColor = status.neutral.color;
} else if (isActive) { } else if (isActive) {
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}'; statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.green; statusColor = status.success.color;
} else if (hasPosition) { } else if (hasPosition) {
statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}'; statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}';
statusColor = Colors.grey; statusColor = status.neutral.color;
} else { } else {
statusText = 'No location data'; statusText = 'No location data';
statusColor = Colors.grey; statusColor = status.neutral.color;
} }
final IconData statusIcon; final IconData statusIcon;
+183
View File
@@ -0,0 +1,183 @@
import 'package:flutter/material.dart';
/// Semantic status categories shared across status pills, chips and badges.
enum StatusKind { success, warning, danger, info, neutral }
/// A single semantic status swatch: a vivid [color] for icons/dots/text, plus a
/// tonal [container] / [onContainer] pair for filled pills and badges.
@immutable
class StatusSwatch {
const StatusSwatch({
required this.color,
required this.container,
required this.onContainer,
});
/// Vivid accent — use for status dots, icons, borders and emphasized text.
final Color color;
/// Tonal fill for pills/badges/callout surfaces.
final Color container;
/// Foreground (text/icon) drawn on top of [container].
final Color onContainer;
StatusSwatch copyWith({Color? color, Color? container, Color? onContainer}) {
return StatusSwatch(
color: color ?? this.color,
container: container ?? this.container,
onContainer: onContainer ?? this.onContainer,
);
}
static StatusSwatch lerp(StatusSwatch a, StatusSwatch b, double t) {
return StatusSwatch(
color: Color.lerp(a.color, b.color, t) ?? a.color,
container: Color.lerp(a.container, b.container, t) ?? a.container,
onContainer: Color.lerp(a.onContainer, b.onContainer, t) ?? a.onContainer,
);
}
}
/// Cyber/IT semantic status tokens.
///
/// These replace scattered raw `Colors.green/orange/red/grey` usages so status
/// meaning is consistent and legible in **both** light and dark themes. Access
/// via [AppStatusColors.of] and pick a swatch by meaning:
///
/// ```dart
/// final status = AppStatusColors.of(context);
/// Icon(Icons.circle, size: 10, color: status.success.color); // online / up
/// ```
@immutable
class AppStatusColors extends ThemeExtension<AppStatusColors> {
const AppStatusColors({
required this.success,
required this.warning,
required this.danger,
required this.info,
required this.neutral,
});
/// Resolved, present, online, device-up, healthy.
final StatusSwatch success;
/// In-progress, pending, warning, degraded.
final StatusSwatch warning;
/// Urgent, critical, absent, error, device-down.
final StatusSwatch danger;
/// Informational / low-priority highlight.
final StatusSwatch info;
/// Idle, offline, disabled, muted.
final StatusSwatch neutral;
// ── Dark (cyber-first) ──────────────────────────────────────────────────
static const AppStatusColors dark = AppStatusColors(
success: StatusSwatch(
color: Color(0xFF34D399),
container: Color(0xFF0C4A34),
onContainer: Color(0xFF8FF0C4),
),
warning: StatusSwatch(
color: Color(0xFFFBBF24),
container: Color(0xFF4A3506),
onContainer: Color(0xFFFCE199),
),
danger: StatusSwatch(
color: Color(0xFFF87171),
container: Color(0xFF5A1A1A),
onContainer: Color(0xFFFFC9C4),
),
info: StatusSwatch(
color: Color(0xFF60A5FA),
container: Color(0xFF0E3A5F),
onContainer: Color(0xFFCFE7FF),
),
neutral: StatusSwatch(
color: Color(0xFF94A3B8),
container: Color(0xFF26313D),
onContainer: Color(0xFFCBD5E1),
),
);
// ── Light ───────────────────────────────────────────────────────────────
static const AppStatusColors light = AppStatusColors(
success: StatusSwatch(
color: Color(0xFF15803D),
container: Color(0xFFCDEFD8),
onContainer: Color(0xFF052E16),
),
warning: StatusSwatch(
color: Color(0xFFB45309),
container: Color(0xFFFCEBC7),
onContainer: Color(0xFF3A2606),
),
danger: StatusSwatch(
color: Color(0xFFDC2626),
container: Color(0xFFFBD9D6),
onContainer: Color(0xFF450A0A),
),
info: StatusSwatch(
color: Color(0xFF2563EB),
container: Color(0xFFD6E4FF),
onContainer: Color(0xFF0A2A6B),
),
neutral: StatusSwatch(
color: Color(0xFF64748B),
container: Color(0xFFE2E8F0),
onContainer: Color(0xFF1E293B),
),
);
static AppStatusColors of(BuildContext context) {
return Theme.of(context).extension<AppStatusColors>() ?? dark;
}
/// Resolve the swatch for a semantic [StatusKind].
StatusSwatch byKind(StatusKind kind) {
switch (kind) {
case StatusKind.success:
return success;
case StatusKind.warning:
return warning;
case StatusKind.danger:
return danger;
case StatusKind.info:
return info;
case StatusKind.neutral:
return neutral;
}
}
@override
AppStatusColors copyWith({
StatusSwatch? success,
StatusSwatch? warning,
StatusSwatch? danger,
StatusSwatch? info,
StatusSwatch? neutral,
}) {
return AppStatusColors(
success: success ?? this.success,
warning: warning ?? this.warning,
danger: danger ?? this.danger,
info: info ?? this.info,
neutral: neutral ?? this.neutral,
);
}
@override
AppStatusColors lerp(ThemeExtension<AppStatusColors>? other, double t) {
if (other is! AppStatusColors) return this;
return AppStatusColors(
success: StatusSwatch.lerp(success, other.success, t),
warning: StatusSwatch.lerp(warning, other.warning, t),
danger: StatusSwatch.lerp(danger, other.danger, t),
info: StatusSwatch.lerp(info, other.info, t),
neutral: StatusSwatch.lerp(neutral, other.neutral, t),
);
}
}
+15 -14
View File
@@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M3 Expressive surface tokens. /// Cyber/IT surface tokens.
/// ///
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows. /// Cards use **tonal elevation** (color tints) plus a hairline outline instead
/// Large containers adopt the M3 standard 28 dp corner radius; compact items /// of drop-shadows. The shape language is tightened to a precise 12–16 dp
/// use 16 dp; small chips/badges use 12 dp. /// technical feel: standard cards 12 dp, compact items 10 dp, large containers
/// 16 dp, dialogs/sheets 20 dp, and chips/badges 8 dp.
@immutable @immutable
class AppSurfaces extends ThemeExtension<AppSurfaces> { class AppSurfaces extends ThemeExtension<AppSurfaces> {
const AppSurfaces({ const AppSurfaces({
@@ -15,19 +16,19 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
required this.chipRadius, required this.chipRadius,
}); });
/// Standard card radius – 16 dp (M3 medium shape). /// Standard card radius – 12 dp (cyber/IT medium shape).
final double cardRadius; final double cardRadius;
/// Compact card radius for dense list tiles – 12 dp. /// Compact card radius for dense list tiles – 10 dp.
final double compactCardRadius; final double compactCardRadius;
/// Large container radius – 28 dp (M3 Expressive). /// Large container radius – 16 dp.
final double containerRadius; final double containerRadius;
/// Dialog / bottom-sheet radius – 28 dp. /// Dialog / bottom-sheet radius – 20 dp.
final double dialogRadius; final double dialogRadius;
/// Chip / badge radius – 12 dp. /// Chip / badge radius – 8 dp.
final double chipRadius; final double chipRadius;
// convenience shapes // convenience shapes
@@ -46,11 +47,11 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
final ext = Theme.of(context).extension<AppSurfaces>(); final ext = Theme.of(context).extension<AppSurfaces>();
return ext ?? return ext ??
const AppSurfaces( const AppSurfaces(
cardRadius: 16, cardRadius: 12,
compactCardRadius: 12, compactCardRadius: 10,
containerRadius: 28, containerRadius: 16,
dialogRadius: 28, dialogRadius: 20,
chipRadius: 12, chipRadius: 8,
); );
} }
+187 -91
View File
@@ -3,57 +3,140 @@ import 'package:google_fonts/google_fonts.dart';
import 'app_typography.dart'; import 'app_typography.dart';
import 'app_surfaces.dart'; import 'app_surfaces.dart';
import 'app_status_colors.dart';
/// M3 Expressive theme for TasQ. /// Refined enterprise **Cyber/IT** theme for TasQ, built on Material 3.
/// ///
/// Key differences from the previous Hybrid M2/M3 theme: /// Identity:
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of /// * Deep slate surfaces (dark) / cool near-white (light) with an electric
/// drop-shadows, giving surfaces an organic, seed-tinted look. /// **azure** primary, **teal** secondary and **violet** tertiary.
/// * Large containers use the M3 standard **28 dp** corner radius. /// * Cards use a flat tonal fill plus a hairline outline (no drop shadow) for a
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated, /// precise, technical feel.
/// Outlined, and Text. /// * A tightened 12–16 dp shape language (see [AppSurfaces]).
/// * NavigationBar / NavigationRail use pill-shaped indicators with the /// * Monospace ([AppMonoText]) reserved for technical data — IDs, IPs, ports,
/// secondary-container tonal color. /// timestamps — and chip labels.
/// * Spring-physics inspired durations: transitions default to 400 ms with an /// * Semantic status tokens ([AppStatusColors]) drive all status/priority color.
/// emphasized easing curve.
class AppTheme { class AppTheme {
/// The seed color drives M3's entire tonal palette generation. const AppTheme._();
static const Color _seed = Color(0xFF4A6FA5);
/// Brand azure — anchors M3's neutral tonal ramp generation.
static const Color _seed = Color(0xFF2D7FF9);
static const AppSurfaces _surfaces = AppSurfaces(
cardRadius: 12,
compactCardRadius: 10,
containerRadius: 16,
dialogRadius: 20,
chipRadius: 8,
);
// ────────────────────────────────────────────────────────────
// COLOR SCHEMES (hand-tuned cyber palette)
// ────────────────────────────────────────────────────────────
static ColorScheme _darkScheme() {
return ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark,
).copyWith(
primary: const Color(0xFF4EA8FF),
onPrimary: const Color(0xFF04263F),
primaryContainer: const Color(0xFF0E3A5F),
onPrimaryContainer: const Color(0xFFCFE7FF),
secondary: const Color(0xFF2DD4BF),
onSecondary: const Color(0xFF00201C),
secondaryContainer: const Color(0xFF0C4B45),
onSecondaryContainer: const Color(0xFFB8F5EC),
tertiary: const Color(0xFFA78BFA),
onTertiary: const Color(0xFF1C0E3F),
tertiaryContainer: const Color(0xFF3A2A6B),
onTertiaryContainer: const Color(0xFFE7DDFF),
error: const Color(0xFFF87171),
onError: const Color(0xFF2A0A0A),
errorContainer: const Color(0xFF5A1A1A),
onErrorContainer: const Color(0xFFFFC9C4),
surface: const Color(0xFF0E141B),
onSurface: const Color(0xFFE3E9F0),
onSurfaceVariant: const Color(0xFFA7B4C2),
surfaceContainerLowest: const Color(0xFF080C11),
surfaceContainerLow: const Color(0xFF141B23),
surfaceContainer: const Color(0xFF17202A),
surfaceContainerHigh: const Color(0xFF1E2833),
surfaceContainerHighest: const Color(0xFF26313D),
surfaceDim: const Color(0xFF0B0F14),
surfaceBright: const Color(0xFF2A3542),
outline: const Color(0xFF3A4A5A),
outlineVariant: const Color(0xFF283440),
surfaceTint: const Color(0xFF4EA8FF),
inverseSurface: const Color(0xFFE3E9F0),
onInverseSurface: const Color(0xFF111820),
inversePrimary: const Color(0xFF0B6BCB),
);
}
static ColorScheme _lightScheme() {
return ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.light,
).copyWith(
primary: const Color(0xFF0B6BCB),
onPrimary: const Color(0xFFFFFFFF),
primaryContainer: const Color(0xFFD3E7FF),
onPrimaryContainer: const Color(0xFF00294A),
secondary: const Color(0xFF0F766E),
onSecondary: const Color(0xFFFFFFFF),
secondaryContainer: const Color(0xFFB8F1E8),
onSecondaryContainer: const Color(0xFF00201C),
tertiary: const Color(0xFF6D28D9),
onTertiary: const Color(0xFFFFFFFF),
tertiaryContainer: const Color(0xFFE9DDFF),
onTertiaryContainer: const Color(0xFF23005C),
error: const Color(0xFFDC2626),
onError: const Color(0xFFFFFFFF),
errorContainer: const Color(0xFFFBD9D6),
onErrorContainer: const Color(0xFF450A0A),
surface: const Color(0xFFF6F8FB),
onSurface: const Color(0xFF111820),
onSurfaceVariant: const Color(0xFF42505F),
surfaceContainerLowest: const Color(0xFFFFFFFF),
surfaceContainerLow: const Color(0xFFF0F4F9),
surfaceContainer: const Color(0xFFEDF1F6),
surfaceContainerHigh: const Color(0xFFE4EAF2),
surfaceContainerHighest: const Color(0xFFDCE4EE),
surfaceDim: const Color(0xFFD7DEE8),
surfaceBright: const Color(0xFFF6F8FB),
outline: const Color(0xFF73808F),
outlineVariant: const Color(0xFFC3CDDA),
surfaceTint: const Color(0xFF0B6BCB),
inverseSurface: const Color(0xFF111820),
onInverseSurface: const Color(0xFFEFF3F8),
inversePrimary: const Color(0xFFA6C8FF),
);
}
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// LIGHT // LIGHT
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
static ThemeData light() { static ThemeData light() {
final base = ThemeData( return _apply(
colorScheme: ColorScheme.fromSeed( ThemeData(colorScheme: _lightScheme(), useMaterial3: true),
seedColor: _seed, AppStatusColors.light,
brightness: Brightness.light,
),
useMaterial3: true,
); );
return _apply(base);
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// DARK // DARK
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
static ThemeData dark() { static ThemeData dark() {
final base = ThemeData( return _apply(
colorScheme: ColorScheme.fromSeed( ThemeData(colorScheme: _darkScheme(), useMaterial3: true),
seedColor: _seed, AppStatusColors.dark,
brightness: Brightness.dark,
),
useMaterial3: true,
); );
return _apply(base);
} }
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
// SHARED BUILDER // SHARED BUILDER
// ──────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────
static ThemeData _apply(ThemeData base) { static ThemeData _apply(ThemeData base, AppStatusColors status) {
final cs = base.colorScheme; final cs = base.colorScheme;
final isDark = cs.brightness == Brightness.dark; final isDark = cs.brightness == Brightness.dark;
@@ -68,18 +151,17 @@ class AppTheme {
const TextStyle(letterSpacing: 0.2), const TextStyle(letterSpacing: 0.2),
); );
const surfaces = AppSurfaces( final surfaces = _surfaces;
cardRadius: 16,
compactCardRadius: 12, // Card fill: flat tonal surface + hairline outline (cyber "panel" look).
containerRadius: 28, final cardColor = isDark ? cs.surfaceContainer : cs.surfaceContainerLowest;
dialogRadius: 28,
chipRadius: 12,
);
return base.copyWith( return base.copyWith(
textTheme: textTheme, textTheme: textTheme,
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest, scaffoldBackgroundColor: isDark
extensions: [mono, surfaces], ? const Color(0xFF0B0F14)
: cs.surfaceContainerLowest,
extensions: [mono, surfaces, status],
// ── AppBar ────────────────────────────────────────────── // ── AppBar ──────────────────────────────────────────────
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
@@ -99,22 +181,27 @@ class AppTheme {
), ),
), ),
// ── Cards — M3 Elevated (tonal surface tint, no hard shadow) ── // ── Cards — flat tonal fill + hairline outline (no hard shadow) ──
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow, color: cardColor,
elevation: 1, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint, surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
side: BorderSide(color: cs.outlineVariant),
),
), ),
// ── Chips ─────────────────────────────────────────────── // ── Chips — mono label, tight radius ────────────────────
chipTheme: ChipThemeData( chipTheme: ChipThemeData(
backgroundColor: cs.surfaceContainerHighest, backgroundColor: cs.surfaceContainerHighest,
side: BorderSide.none, side: BorderSide(color: cs.outlineVariant),
labelStyle: textTheme.labelSmall, labelStyle: mono.label,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.chipRadius),
),
), ),
// ── Dividers ──────────────────────────────────────────── // ── Dividers ────────────────────────────────────────────
@@ -123,17 +210,17 @@ class AppTheme {
// ── Input Fields ──────────────────────────────────────── // ── Input Fields ────────────────────────────────────────
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: cs.surfaceContainerLow, fillColor: isDark ? cs.surfaceContainerLow : cs.surfaceContainer,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.outlineVariant), borderSide: BorderSide(color: cs.outlineVariant),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.outlineVariant), borderSide: BorderSide(color: cs.outlineVariant),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
borderSide: BorderSide(color: cs.primary, width: 2), borderSide: BorderSide(color: cs.primary, width: 2),
), ),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
@@ -142,87 +229,84 @@ class AppTheme {
), ),
), ),
// ── Buttons — M3 Expressive hierarchy ─────────────────── // ── Buttons — M3 hierarchy, tightened shapes ────────────
filledButtonTheme: FilledButtonThemeData( filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
), ),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith( textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
),
), ),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: cs.surfaceContainerLow, backgroundColor: cs.surfaceContainerHigh,
foregroundColor: cs.primary, foregroundColor: cs.primary,
elevation: 1, elevation: 0,
shadowColor: Colors.transparent, shadowColor: Colors.transparent,
surfaceTintColor: cs.surfaceTint, surfaceTintColor: Colors.transparent,
side: BorderSide(color: cs.outlineVariant),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
), ),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
textStyle: textTheme.labelLarge?.copyWith( textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
),
), ),
), ),
outlinedButtonTheme: OutlinedButtonThemeData( outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
), ),
side: BorderSide(color: cs.outline), side: BorderSide(color: cs.outline),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
textStyle: textTheme.labelLarge?.copyWith( textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
),
), ),
), ),
textButtonTheme: TextButtonThemeData( textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom( style: TextButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
), ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
textStyle: textTheme.labelLarge?.copyWith( textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
fontWeight: FontWeight.w600,
),
), ),
), ),
iconButtonTheme: IconButtonThemeData( iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom( style: IconButton.styleFrom(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
), ),
), ),
), ),
segmentedButtonTheme: SegmentedButtonThemeData( segmentedButtonTheme: SegmentedButtonThemeData(
style: ButtonStyle( style: ButtonStyle(
shape: WidgetStateProperty.all( shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
), ),
), ),
), ),
// ── FAB — M3 Expressive ────────────────────────────────── // ── FAB ──────────────────────────────────────────────────
floatingActionButtonTheme: FloatingActionButtonThemeData( floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: cs.primaryContainer, backgroundColor: cs.primaryContainer,
foregroundColor: cs.onPrimaryContainer, foregroundColor: cs.onPrimaryContainer,
elevation: 3, elevation: 3,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
), ),
// ── Navigation — M3 Expressive pill indicators ────────── // ── Navigation — pill indicators ────────────────────────
navigationDrawerTheme: NavigationDrawerThemeData( navigationDrawerTheme: NavigationDrawerThemeData(
backgroundColor: cs.surface, backgroundColor: cs.surface,
indicatorColor: cs.secondaryContainer, indicatorColor: cs.secondaryContainer,
tileHeight: 56, tileHeight: 56,
indicatorShape: RoundedRectangleBorder( indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(surfaces.containerRadius),
), ),
), ),
navigationRailTheme: NavigationRailThemeData( navigationRailTheme: NavigationRailThemeData(
@@ -238,14 +322,14 @@ class AppTheme {
), ),
indicatorColor: cs.secondaryContainer, indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder( indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(surfaces.containerRadius),
), ),
), ),
navigationBarTheme: NavigationBarThemeData( navigationBarTheme: NavigationBarThemeData(
backgroundColor: cs.surfaceContainer, backgroundColor: cs.surfaceContainer,
indicatorColor: cs.secondaryContainer, indicatorColor: cs.secondaryContainer,
indicatorShape: RoundedRectangleBorder( indicatorShape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(surfaces.cardRadius),
), ),
labelTextStyle: WidgetStateProperty.resolveWith((states) { labelTextStyle: WidgetStateProperty.resolveWith((states) {
final selected = states.contains(WidgetState.selected); final selected = states.contains(WidgetState.selected);
@@ -260,15 +344,19 @@ class AppTheme {
// ── List Tiles ────────────────────────────────────────── // ── List Tiles ──────────────────────────────────────────
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.cardRadius),
),
tileColor: Colors.transparent, tileColor: Colors.transparent,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
), ),
// ── Dialogs ───────────────────────────────────────────── // ── Dialogs ─────────────────────────────────────────────
dialogTheme: DialogThemeData( dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), shape: RoundedRectangleBorder(
surfaceTintColor: cs.surfaceTint, borderRadius: BorderRadius.circular(surfaces.dialogRadius),
),
surfaceTintColor: Colors.transparent,
backgroundColor: isDark backgroundColor: isDark
? cs.surfaceContainerHigh ? cs.surfaceContainerHigh
: cs.surfaceContainerLowest, : cs.surfaceContainerLowest,
@@ -276,20 +364,24 @@ class AppTheme {
// ── Bottom Sheets ─────────────────────────────────────── // ── Bottom Sheets ───────────────────────────────────────
bottomSheetTheme: BottomSheetThemeData( bottomSheetTheme: BottomSheetThemeData(
shape: const RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)), borderRadius: BorderRadius.vertical(
top: Radius.circular(surfaces.dialogRadius),
),
), ),
backgroundColor: isDark backgroundColor: isDark
? cs.surfaceContainerHigh ? cs.surfaceContainerHigh
: cs.surfaceContainerLowest, : cs.surfaceContainerLowest,
surfaceTintColor: cs.surfaceTint, surfaceTintColor: Colors.transparent,
showDragHandle: true, showDragHandle: true,
), ),
// ── Snackbar ──────────────────────────────────────────── // ── Snackbar ────────────────────────────────────────────
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
),
), ),
// ── Search Bar ────────────────────────────────────────── // ── Search Bar ──────────────────────────────────────────
@@ -297,7 +389,9 @@ class AppTheme {
elevation: WidgetStateProperty.all(0), elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh), backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
shape: WidgetStateProperty.all( shape: WidgetStateProperty.all(
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), RoundedRectangleBorder(
borderRadius: BorderRadius.circular(surfaces.containerRadius),
),
), ),
), ),
@@ -305,7 +399,7 @@ class AppTheme {
tooltipTheme: TooltipThemeData( tooltipTheme: TooltipThemeData(
decoration: BoxDecoration( decoration: BoxDecoration(
color: cs.inverseSurface, color: cs.inverseSurface,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(surfaces.chipRadius),
), ),
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface), textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
), ),
@@ -323,8 +417,10 @@ class AppTheme {
// ── PopupMenu ─────────────────────────────────────────── // ── PopupMenu ───────────────────────────────────────────
popupMenuTheme: PopupMenuThemeData( popupMenuTheme: PopupMenuThemeData(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
surfaceTintColor: cs.surfaceTint, borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
),
surfaceTintColor: Colors.transparent,
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest, color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
), ),
); );
+4 -3
View File
@@ -9,6 +9,7 @@ import '../providers/attendance_provider.dart';
import '../providers/profile_provider.dart'; import '../providers/profile_provider.dart';
import '../services/face_verification.dart' as face; import '../services/face_verification.dart' as face;
import '../services/image_compress_service.dart'; import '../services/image_compress_service.dart';
import '../theme/app_status_colors.dart';
import '../theme/m3_motion.dart'; import '../theme/m3_motion.dart';
import '../widgets/qr_verification_dialog.dart'; import '../widgets/qr_verification_dialog.dart';
@@ -302,7 +303,7 @@ class _FaceVerificationOverlayState
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
color: _phase == _Phase.success color: _phase == _Phase.success
? Colors.green ? AppStatusColors.of(context).success.color
: _phase == _Phase.failed : _phase == _Phase.failed
? colors.error ? colors.error
: _phase == _Phase.saving : _phase == _Phase.saving
@@ -337,7 +338,7 @@ class _FaceVerificationOverlayState
final Color ringColor; final Color ringColor;
if (isSuccess) { if (isSuccess) {
ringColor = Colors.green; ringColor = AppStatusColors.of(context).success.color;
} else if (isFailed) { } else if (isFailed) {
ringColor = colors.error; ringColor = colors.error;
} else if (isSaving) { } else if (isSaving) {
@@ -461,7 +462,7 @@ class _FaceVerificationOverlayState
Color dotColor; Color dotColor;
if (isSuccess_) { if (isSuccess_) {
dotColor = Colors.green; dotColor = AppStatusColors.of(context).success.color;
} else if (isDone) { } else if (isDone) {
dotColor = colors.error.withValues(alpha: 0.5); dotColor = colors.error.withValues(alpha: 0.5);
} else if (isCurrent) { } else if (isCurrent) {
+52 -43
View File
@@ -1,26 +1,27 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M3 Expressive Card variants. import '../theme/app_surfaces.dart';
enum _M3CardVariant { elevated, filled, outlined }
/// Cyber/IT Card variants (Material 3).
/// ///
/// Use these factory constructors to build semantically correct cards: /// Use these factory constructors to build semantically correct cards:
/// - [M3Card.elevated] — default, uses tonal surface tint (no hard shadow). /// - [M3Card.elevated] — default panel: tonal fill + hairline outline (flat).
/// - [M3Card.filled] — uses surfaceContainerHighest for emphasis. /// - [M3Card.filled] — high-emphasis fill (caller passes [color]); no border.
/// - [M3Card.outlined] — transparent fill with a subtle outline border. /// - [M3Card.outlined] — transparent fill with a hairline outline border.
class M3Card extends StatelessWidget { class M3Card extends StatelessWidget {
const M3Card._({ const M3Card._(
this._variant, {
required this.child, required this.child,
this.color, this.color,
this.elevation,
this.shadowColor,
this.surfaceTintColor,
this.shape, this.shape,
this.margin, this.margin,
this.clipBehavior = Clip.none, this.clipBehavior = Clip.none,
this.onTap, this.onTap,
}); });
/// Elevated card — tonal surface tint, minimal shadow. /// Elevated card — default panel with a tonal fill + hairline outline.
/// Best for primary content surfaces (metric cards, detail panels). /// Best for primary content surfaces (metric cards, detail panels).
factory M3Card.elevated({ factory M3Card.elevated({
required Widget child, required Widget child,
@@ -31,9 +32,8 @@ class M3Card extends StatelessWidget {
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return M3Card._( return M3Card._(
_M3CardVariant.elevated,
color: color, color: color,
elevation: 1,
shadowColor: Colors.transparent,
shape: shape, shape: shape,
margin: margin, margin: margin,
clipBehavior: clipBehavior, clipBehavior: clipBehavior,
@@ -42,8 +42,8 @@ class M3Card extends StatelessWidget {
); );
} }
/// Filled card — uses surfaceContainerHighest for high emphasis. /// Filled card — a high-emphasis fill (caller passes a semantic [color] or a
/// Best for summary cards, status counts, callout panels. /// surfaceContainer tone). No border. Best for summary/callout panels.
factory M3Card.filled({ factory M3Card.filled({
required Widget child, required Widget child,
Color? color, Color? color,
@@ -53,10 +53,8 @@ class M3Card extends StatelessWidget {
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return M3Card._( return M3Card._(
color: color, // caller passes surfaceContainerHighest or semantic color _M3CardVariant.filled,
elevation: 0, color: color,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shape: shape, shape: shape,
margin: margin, margin: margin,
clipBehavior: clipBehavior, clipBehavior: clipBehavior,
@@ -65,7 +63,7 @@ class M3Card extends StatelessWidget {
); );
} }
/// Outlined card — transparent fill with outline border. /// Outlined card — transparent fill with a hairline outline border.
/// Best for list items, form sections, grouped content. /// Best for list items, form sections, grouped content.
factory M3Card.outlined({ factory M3Card.outlined({
required Widget child, required Widget child,
@@ -76,10 +74,8 @@ class M3Card extends StatelessWidget {
VoidCallback? onTap, VoidCallback? onTap,
}) { }) {
return M3Card._( return M3Card._(
_M3CardVariant.outlined,
color: color, color: color,
elevation: 0,
shadowColor: Colors.transparent,
surfaceTintColor: Colors.transparent,
shape: shape, shape: shape,
margin: margin, margin: margin,
clipBehavior: clipBehavior, clipBehavior: clipBehavior,
@@ -88,11 +84,9 @@ class M3Card extends StatelessWidget {
); );
} }
final _M3CardVariant _variant;
final Widget child; final Widget child;
final Color? color; final Color? color;
final double? elevation;
final Color? shadowColor;
final Color? surfaceTintColor;
final ShapeBorder? shape; final ShapeBorder? shape;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final Clip clipBehavior; final Clip clipBehavior;
@@ -101,36 +95,51 @@ class M3Card extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final radius = AppSurfaces.of(context).cardRadius;
final isDark = cs.brightness == Brightness.dark;
final Color resolvedColor;
final bool bordered;
switch (_variant) {
case _M3CardVariant.elevated:
resolvedColor =
color ??
(isDark ? cs.surfaceContainer : cs.surfaceContainerLowest);
bordered = true;
case _M3CardVariant.filled:
resolvedColor = color ?? cs.surfaceContainerHighest;
bordered = false;
case _M3CardVariant.outlined:
resolvedColor = color ?? Colors.transparent;
bordered = true;
}
// For outlined, we need the border side
final resolvedShape = final resolvedShape =
shape ?? shape ??
(elevation == 0 && surfaceTintColor == Colors.transparent RoundedRectangleBorder(
? RoundedRectangleBorder( borderRadius: BorderRadius.circular(radius),
borderRadius: BorderRadius.circular(16), side: bordered
side: (shadowColor == Colors.transparent && color == null) ? BorderSide(color: cs.outlineVariant)
? BorderSide(color: cs.outlineVariant) : BorderSide.none,
: BorderSide.none, );
)
: null);
final card = Card( return Card(
color: color, color: resolvedColor,
elevation: elevation, elevation: 0,
shadowColor: shadowColor, shadowColor: Colors.transparent,
surfaceTintColor: surfaceTintColor, surfaceTintColor: Colors.transparent,
shape: resolvedShape, shape: resolvedShape,
margin: margin ?? EdgeInsets.zero, margin: margin ?? EdgeInsets.zero,
clipBehavior: clipBehavior, clipBehavior: onTap != null && clipBehavior == Clip.none
? Clip.antiAlias
: clipBehavior,
child: onTap != null child: onTap != null
? InkWell( ? InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(radius),
child: child, child: child,
) )
: child, : child,
); );
return card;
} }
} }
+41 -13
View File
@@ -1,32 +1,60 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// M3 Expressive status pill — uses tonal container colors with a import '../theme/app_status_colors.dart';
/// smooth, spring-physics-inspired animation. import '../theme/app_surfaces.dart';
/// Cyber/IT status pill — a compact, uppercase, mono-friendly badge whose color
/// carries meaning via [AppStatusColors].
///
/// Two constructors:
/// * [StatusPill.kind] (preferred) — pass a semantic [StatusKind] and it pulls
/// the correct tonal container/foreground from the theme, consistent in both
/// light and dark.
/// * The default constructor keeps the older `isEmphasized` behaviour for
/// back-compat (primaryContainer vs. tertiaryContainer).
class StatusPill extends StatelessWidget { class StatusPill extends StatelessWidget {
const StatusPill({super.key, required this.label, this.isEmphasized = false}); const StatusPill({super.key, required this.label, this.isEmphasized = false})
: kind = null;
/// Semantic variant — the recommended way to build a status pill.
const StatusPill.kind(
this.kind, {
super.key,
required this.label,
}) : isEmphasized = false;
final String label; final String label;
final bool isEmphasized; final bool isEmphasized;
final StatusKind? kind;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
// isEmphasized uses primaryContainer (higher tonal weight) to visually final radius = AppSurfaces.of(context).chipRadius;
// distinguish high-priority statuses from routine ones.
final background = isEmphasized late final Color background;
? scheme.primaryContainer late final Color foreground;
: scheme.tertiaryContainer; if (kind != null) {
final foreground = isEmphasized final swatch = AppStatusColors.of(context).byKind(kind!);
? scheme.onPrimaryContainer background = swatch.container;
: scheme.onTertiaryContainer; foreground = swatch.onContainer;
} else {
// Legacy path: emphasis maps to primary vs. tertiary containers.
background = isEmphasized
? scheme.primaryContainer
: scheme.tertiaryContainer;
foreground = isEmphasized
? scheme.onPrimaryContainer
: scheme.onTertiaryContainer;
}
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: background,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(radius),
), ),
child: Text( child: Text(
label.toUpperCase(), label.toUpperCase(),
+5 -3
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/connectivity_provider.dart'; import '../providers/connectivity_provider.dart';
import '../theme/app_status_colors.dart';
/// Two-state "this item is not yet synced" indicator. /// Two-state "this item is not yet synced" indicator.
/// ///
@@ -38,6 +39,7 @@ class SyncPendingBadge extends ConsumerWidget {
final isOnline = ref.watch(isOnlineProvider); final isOnline = ref.watch(isOnlineProvider);
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final status = AppStatusColors.of(context);
final Color bg; final Color bg;
final Color fg; final Color fg;
@@ -53,9 +55,9 @@ class SyncPendingBadge extends ConsumerWidget {
text = label ?? 'Syncing…'; text = label ?? 'Syncing…';
spin = true; spin = true;
} else { } else {
// Queued offline: amber, static. // Queued offline: warning tone, static.
bg = Colors.amber.shade100; bg = status.warning.container;
fg = Colors.amber.shade900; fg = status.warning.onContainer;
icon = Icons.sync; icon = Icons.sync;
text = label ?? 'Pending sync'; text = label ?? 'Pending sync';
spin = false; spin = false;
+92
View File
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../theme/app_status_colors.dart';
import '../theme/app_surfaces.dart';
import '../theme/app_typography.dart';
/// A compact, monospace "code" chip for technical identifiers — ticket/task
/// numbers, IP/MAC addresses, ports, VLAN ids, device codes, short timestamps.
///
/// Reinforces the Cyber/IT identity by rendering machine data in Roboto Mono on
/// a subtle tonal surface with a hairline outline. Optionally tint it with a
/// semantic [StatusKind] for at-a-glance meaning.
class TechChip extends StatelessWidget {
const TechChip(
this.text, {
super.key,
this.icon,
this.status,
this.onTap,
this.dense = false,
});
final String text;
/// Optional leading glyph (e.g. `Icons.tag`, `Icons.lan_outlined`).
final IconData? icon;
/// When set, tints the chip with the matching [AppStatusColors] swatch.
final StatusKind? status;
final VoidCallback? onTap;
/// Tighter padding + smaller glyph for inline use within dense rows.
final bool dense;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final radius = AppSurfaces.of(context).chipRadius;
final swatch = status == null
? null
: AppStatusColors.of(context).byKind(status!);
final background = swatch?.container ?? cs.surfaceContainerHighest;
final foreground = swatch?.onContainer ?? cs.onSurfaceVariant;
final border = swatch == null ? cs.outlineVariant : Colors.transparent;
final labelStyle = AppMonoText.of(context).label.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
height: 1.0,
);
final content = Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: dense ? 12 : 14, color: foreground),
SizedBox(width: dense ? 4 : 6),
],
Flexible(
child: Text(
text,
style: labelStyle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
final chip = Container(
padding: EdgeInsets.symmetric(
horizontal: dense ? 6 : 8,
vertical: dense ? 2 : 4,
),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(radius),
border: Border.all(color: border),
),
child: content,
);
if (onTap == null) return chip;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(radius),
child: chip,
);
}
}