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:
@@ -1,4 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
@@ -111,8 +113,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
.check_circle
|
||||
: Icons.cancel,
|
||||
color: inside
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
? AppStatusColors.of(context).success.color
|
||||
: AppStatusColors.of(context).danger.color,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
@@ -166,8 +168,8 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
? Icons.check_circle
|
||||
: Icons.cancel,
|
||||
color: inside
|
||||
? Colors.green
|
||||
: Colors.red,
|
||||
? AppStatusColors.of(context).success.color
|
||||
: AppStatusColors.of(context).danger.color,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
@@ -275,10 +277,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
width: 40,
|
||||
height: 40,
|
||||
// `child` is used by newer flutter_map Marker API.
|
||||
child: const Icon(
|
||||
child: Icon(
|
||||
Icons.person_pin_circle,
|
||||
size: 36,
|
||||
color: Colors.blue,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -289,10 +291,12 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
polygons: [
|
||||
Polygon(
|
||||
points: polygonPoints,
|
||||
color: Colors.green.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
borderColor: Colors.green,
|
||||
color: AppStatusColors.of(context)
|
||||
.success
|
||||
.color
|
||||
.withValues(alpha: 0.15),
|
||||
borderColor:
|
||||
AppStatusColors.of(context).success.color,
|
||||
borderStrokeWidth: 2,
|
||||
),
|
||||
],
|
||||
@@ -304,9 +308,10 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
circles: [
|
||||
CircleMarker(
|
||||
point: LatLng(cfg!.lat!, cfg.lng!),
|
||||
color: Colors.green.withValues(
|
||||
alpha: 0.10,
|
||||
),
|
||||
color: AppStatusColors.of(context)
|
||||
.success
|
||||
.color
|
||||
.withValues(alpha: 0.10),
|
||||
borderStrokeWidth: 2,
|
||||
useRadiusInMeter: true,
|
||||
radius: cfg.radiusMeters ?? 100,
|
||||
@@ -387,11 +392,15 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(
|
||||
color: AppStatusColors.of(
|
||||
context,
|
||||
).success.color.withValues(
|
||||
alpha: 0.25,
|
||||
),
|
||||
border: Border.all(
|
||||
color: Colors.green,
|
||||
color: AppStatusColors.of(
|
||||
context,
|
||||
).success.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -420,14 +429,16 @@ class _GeofenceTestScreenState extends ConsumerState<GeofenceTestScreen> {
|
||||
top: 6.0,
|
||||
),
|
||||
child: Row(
|
||||
children: const [
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_pin_circle,
|
||||
size: 14,
|
||||
color: Colors.blue,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text('You'),
|
||||
const SizedBox(width: 8),
|
||||
const Text('You'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -35,6 +35,7 @@ import 'logbook_day_activity.dart';
|
||||
import 'work_log_tab.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/device_security.dart';
|
||||
import '../../utils/location_permission.dart';
|
||||
@@ -787,7 +788,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
} else if (diff.inMinutes <= 5) {
|
||||
timeColor = colors.error;
|
||||
} else if (diff.inMinutes <= 15) {
|
||||
timeColor = Colors.orange;
|
||||
timeColor = AppStatusColors.of(context).warning.color;
|
||||
} else if (diff.inMinutes <= 30) {
|
||||
timeColor = colors.tertiary;
|
||||
}
|
||||
@@ -847,7 +848,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
: Icons.cancel,
|
||||
size: 16,
|
||||
color: _insideGeofence || hasGeofenceOverride
|
||||
? Colors.green
|
||||
? AppStatusColors.of(context).success.color
|
||||
: colors.error,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
@@ -859,7 +860,7 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
: 'Outside geofence',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _insideGeofence || hasGeofenceOverride
|
||||
? Colors.green
|
||||
? AppStatusColors.of(context).success.color
|
||||
: colors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -1863,11 +1864,11 @@ class _CheckInTabState extends ConsumerState<_CheckInTab> {
|
||||
bg = colors.secondaryContainer;
|
||||
fg = colors.onSecondaryContainer;
|
||||
case 'Early Out':
|
||||
bg = Colors.orange.shade100;
|
||||
fg = Colors.orange.shade900;
|
||||
bg = AppStatusColors.of(context).warning.container;
|
||||
fg = AppStatusColors.of(context).warning.onContainer;
|
||||
case 'Noon Break':
|
||||
bg = Colors.blue.shade100;
|
||||
fg = Colors.blue.shade900;
|
||||
bg = AppStatusColors.of(context).info.container;
|
||||
fg = AppStatusColors.of(context).info.onContainer;
|
||||
case 'Absent':
|
||||
bg = colors.errorContainer;
|
||||
fg = colors.onErrorContainer;
|
||||
@@ -2841,13 +2842,14 @@ class _DateGroupTile extends StatelessWidget {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
final status = AppStatusColors.of(context);
|
||||
final statusColor = group.status == 'Absent'
|
||||
? Colors.red
|
||||
? status.danger.color
|
||||
: group.status == 'On duty'
|
||||
? Colors.orange
|
||||
? status.warning.color
|
||||
: group.status == 'On Leave'
|
||||
? Colors.teal
|
||||
: Colors.green;
|
||||
? status.info.color
|
||||
: status.success.color;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
|
||||
@@ -2960,10 +2962,10 @@ class _DateGroupTile extends StatelessWidget {
|
||||
String statusLabel;
|
||||
switch (slip.status) {
|
||||
case 'approved':
|
||||
statusColor = Colors.green;
|
||||
statusColor = AppStatusColors.of(context).success.color;
|
||||
statusLabel = 'Approved';
|
||||
case 'pending':
|
||||
statusColor = Colors.orange;
|
||||
statusColor = AppStatusColors.of(context).warning.color;
|
||||
statusLabel = 'Pending';
|
||||
case 'rejected':
|
||||
statusColor = colors.error;
|
||||
@@ -3081,7 +3083,7 @@ class _DateGroupTile extends StatelessWidget {
|
||||
? Chip(
|
||||
label: Text(entry.isLeave ? 'On Leave' : 'Absent'),
|
||||
backgroundColor: entry.isLeave
|
||||
? Colors.teal.withValues(alpha: 0.15)
|
||||
? AppStatusColors.of(context).info.container
|
||||
: colors.errorContainer,
|
||||
)
|
||||
: entry.status == 'On duty'
|
||||
@@ -3107,7 +3109,7 @@ class _DateGroupTile extends StatelessWidget {
|
||||
switch (entry.verificationStatus) {
|
||||
case 'verified':
|
||||
icon = Icons.verified;
|
||||
color = Colors.green;
|
||||
color = AppStatusColors.of(context).success.color;
|
||||
tooltip = 'Verified';
|
||||
case 'unverified' || 'skipped':
|
||||
icon = Icons.warning_amber_rounded;
|
||||
@@ -3115,7 +3117,7 @@ class _DateGroupTile extends StatelessWidget {
|
||||
tooltip = 'Unverified';
|
||||
default:
|
||||
icon = Icons.hourglass_bottom;
|
||||
color = Colors.orange;
|
||||
color = AppStatusColors.of(context).warning.color;
|
||||
tooltip = 'Pending';
|
||||
}
|
||||
final badge = Tooltip(
|
||||
@@ -3526,7 +3528,7 @@ class _CheckInOutMapTab extends StatelessWidget {
|
||||
title: 'Check In',
|
||||
lat: entry.checkInLat,
|
||||
lng: entry.checkInLng,
|
||||
markerColor: Colors.green,
|
||||
markerColor: AppStatusColors.of(context).success.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -3536,7 +3538,7 @@ class _CheckInOutMapTab extends StatelessWidget {
|
||||
title: 'Check Out',
|
||||
lat: entry.checkOutLat,
|
||||
lng: entry.checkOutLng,
|
||||
markerColor: Colors.red,
|
||||
markerColor: AppStatusColors.of(context).danger.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -5830,7 +5832,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
IconData statusIcon;
|
||||
switch (slip.status) {
|
||||
case 'approved':
|
||||
statusColor = Colors.green;
|
||||
statusColor = AppStatusColors.of(context).success.color;
|
||||
statusIcon = Icons.check_circle;
|
||||
case 'rejected':
|
||||
statusColor = colors.error;
|
||||
@@ -5839,7 +5841,7 @@ class _PassSlipTabState extends ConsumerState<_PassSlipTab> {
|
||||
statusColor = colors.primary;
|
||||
statusIcon = Icons.task_alt;
|
||||
default:
|
||||
statusColor = Colors.orange;
|
||||
statusColor = AppStatusColors.of(context).warning.color;
|
||||
statusIcon = Icons.hourglass_top;
|
||||
}
|
||||
|
||||
@@ -6248,7 +6250,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
IconData statusIcon;
|
||||
switch (leave.status) {
|
||||
case 'approved':
|
||||
statusColor = Colors.teal;
|
||||
statusColor = AppStatusColors.of(context).success.color;
|
||||
statusIcon = Icons.check_circle;
|
||||
case 'rejected':
|
||||
statusColor = colors.error;
|
||||
@@ -6257,7 +6259,7 @@ class _LeaveTabState extends ConsumerState<_LeaveTab> {
|
||||
statusColor = colors.onSurfaceVariant;
|
||||
statusIcon = Icons.remove_circle_outline;
|
||||
default:
|
||||
statusColor = Colors.orange;
|
||||
statusColor = AppStatusColors.of(context).warning.color;
|
||||
statusIcon = Icons.hourglass_top;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../models/it_service_request.model.dart';
|
||||
import '../../models/pass_slip.model.dart';
|
||||
import '../../models/task.model.dart';
|
||||
import '../../models/ticket.model.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
|
||||
@@ -427,7 +428,7 @@ class _SummaryChip extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
|
||||
@@ -563,23 +564,24 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen>
|
||||
}
|
||||
|
||||
final normalized = (score / 6).clamp(0.0, 1.0);
|
||||
final s = AppStatusColors.of(context);
|
||||
String label;
|
||||
Color color;
|
||||
if (normalized <= 0.2) {
|
||||
label = 'Very weak';
|
||||
color = Colors.red;
|
||||
color = s.danger.color;
|
||||
} else if (normalized <= 0.4) {
|
||||
label = 'Weak';
|
||||
color = Colors.deepOrange;
|
||||
color = s.danger.color;
|
||||
} else if (normalized <= 0.6) {
|
||||
label = 'Fair';
|
||||
color = Colors.orange;
|
||||
color = s.warning.color;
|
||||
} else if (normalized <= 0.8) {
|
||||
label = 'Strong';
|
||||
color = Colors.green;
|
||||
color = s.info.color;
|
||||
} else {
|
||||
label = 'Excellent';
|
||||
color = Colors.teal;
|
||||
color = s.success.color;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
|
||||
@@ -38,6 +38,7 @@ import '../../widgets/app_breakpoints.dart';
|
||||
import '../../providers/realtime_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
@@ -776,14 +777,29 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
}
|
||||
|
||||
Widget _sectionTitle(BuildContext context, String title) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 3,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.8,
|
||||
color: cs.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -918,21 +934,26 @@ class _MetricCard extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
);
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).cardRadius,
|
||||
),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
title.toUpperCase(),
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.6,
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
@@ -967,14 +988,16 @@ class _StaffTable extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
// M3 Expressive: tonal surface container, 28 dp radius for large containers.
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
// Cyber/IT panel: flat tonal fill + hairline outline.
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -1059,18 +1082,23 @@ class _StaffTableBody extends ConsumerWidget {
|
||||
);
|
||||
|
||||
if (providerState == 'loading') {
|
||||
return const Text('Loading staff...');
|
||||
return _panelHint(context, Icons.hourglass_empty_rounded, 'Loading staff…');
|
||||
}
|
||||
|
||||
if (providerState.startsWith('error:')) {
|
||||
final err = providerState.substring(6);
|
||||
return Text('Failed to load staff: $err');
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.error_outline_rounded,
|
||||
'Failed to load staff: $err',
|
||||
);
|
||||
}
|
||||
|
||||
if (staffRows.isEmpty) {
|
||||
return Text(
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.groups_outlined,
|
||||
'No IT staff available.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1142,20 +1170,24 @@ class _StaffRow extends StatelessWidget {
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Center(
|
||||
child: Text(
|
||||
row.whereabouts,
|
||||
style: valueStyle?.copyWith(
|
||||
color: row.whereabouts == 'In premise'
|
||||
? Colors.green
|
||||
: row.whereabouts == 'Outside premise'
|
||||
? Colors.grey
|
||||
: row.whereabouts == 'Tracking off'
|
||||
? Colors.grey
|
||||
: row.whereabouts.startsWith('Last seen')
|
||||
? Colors.grey
|
||||
: null,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final status = AppStatusColors.of(context);
|
||||
final Color? color = row.whereabouts == 'In premise'
|
||||
? status.success.color
|
||||
: (row.whereabouts == 'Outside premise' ||
|
||||
row.whereabouts == 'Tracking off' ||
|
||||
row.whereabouts.startsWith('Last seen'))
|
||||
? status.neutral.color
|
||||
: null;
|
||||
return Text(
|
||||
row.whereabouts,
|
||||
style: valueStyle?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1202,21 +1234,24 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerLow,
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLowest,
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
border: Border.all(color: cs.outlineVariant),
|
||||
),
|
||||
child: metricsAsync.when(
|
||||
data: (metrics) {
|
||||
final staffRows = metrics.staffRows;
|
||||
if (staffRows.isEmpty) {
|
||||
return Text(
|
||||
return _panelHint(
|
||||
context,
|
||||
Icons.bar_chart_outlined,
|
||||
'No staff activity data available.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1393,16 +1428,16 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(
|
||||
(0.35 * 255).round(),
|
||||
color: cs.inverseSurface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).chipRadius,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
child: MonoText(
|
||||
total.toString(),
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Colors.white,
|
||||
color: cs.onInverseSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
@@ -1534,8 +1569,13 @@ class _StaffActivityChart extends ConsumerWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => Text('Failed to load activity: $error'),
|
||||
loading: () =>
|
||||
_panelHint(context, Icons.hourglass_empty_rounded, 'Loading activity…'),
|
||||
error: (error, stack) => _panelHint(
|
||||
context,
|
||||
Icons.error_outline_rounded,
|
||||
'Failed to load activity: $error',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1556,29 +1596,28 @@ class _PulseStatusPill extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final s = AppStatusColors.of(context);
|
||||
final (Color bg, Color fg) = switch (label.toLowerCase()) {
|
||||
'arrival' => (Colors.amber.shade100, Colors.amber.shade900),
|
||||
'late' => (Colors.red.shade100, Colors.red.shade900),
|
||||
'noon break' => (Colors.blue.shade100, Colors.blue.shade900),
|
||||
'vacant' => (Colors.green.shade100, Colors.green.shade900),
|
||||
'on task' => (Colors.purple.shade100, Colors.purple.shade900),
|
||||
'on event' => (Colors.purple.shade100, Colors.purple.shade900),
|
||||
'in triage' => (Colors.orange.shade100, Colors.orange.shade900),
|
||||
'early out' => (Colors.deepOrange.shade100, Colors.deepOrange.shade900),
|
||||
'on leave' => (Colors.teal.shade100, Colors.teal.shade900),
|
||||
'absent' => (Colors.red.shade200, Colors.red.shade900),
|
||||
'off duty' => (Colors.grey.shade200, Colors.grey.shade700),
|
||||
_ => (
|
||||
Theme.of(context).colorScheme.tertiaryContainer,
|
||||
Theme.of(context).colorScheme.onTertiaryContainer,
|
||||
),
|
||||
'arrival' => (s.success.container, s.success.onContainer),
|
||||
'late' => (s.warning.container, s.warning.onContainer),
|
||||
'noon break' => (s.info.container, s.info.onContainer),
|
||||
'vacant' => (s.neutral.container, s.neutral.onContainer),
|
||||
'on task' => (cs.primaryContainer, cs.onPrimaryContainer),
|
||||
'on event' => (cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
'in triage' => (s.warning.container, s.warning.onContainer),
|
||||
'early out' => (s.warning.container, s.warning.onContainer),
|
||||
'on leave' => (s.info.container, s.info.onContainer),
|
||||
'absent' => (s.danger.container, s.danger.onContainer),
|
||||
'off duty' => (s.neutral.container, s.neutral.onContainer),
|
||||
_ => (cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -1591,6 +1630,31 @@ class _PulseStatusPill extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact, muted, centered hint for empty/loading/error states inside a
|
||||
/// bounded dashboard panel (too small for the full-screen AppEmptyView).
|
||||
Widget _panelHint(BuildContext context, IconData icon, String message) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Duration? _averageDuration(List<Duration> durations) {
|
||||
if (durations.isEmpty) {
|
||||
return null;
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../../widgets/gemini_animated_text_field.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import 'it_service_request_pdf.dart';
|
||||
|
||||
@@ -411,13 +412,13 @@ class _ItServiceRequestDetailScreenState
|
||||
),
|
||||
)
|
||||
: _hasRecentSave
|
||||
? const Padding(
|
||||
key: ValueKey('isr_saved'),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8),
|
||||
? Padding(
|
||||
key: const ValueKey('isr_saved'),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.check_circle,
|
||||
size: 18,
|
||||
color: Colors.green,
|
||||
color: AppStatusColors.of(context).success.color,
|
||||
),
|
||||
)
|
||||
: const SizedBox(key: ValueKey('isr_idle')),
|
||||
@@ -555,7 +556,10 @@ class _ItServiceRequestDetailScreenState
|
||||
if (request.requestNumber != null)
|
||||
MonoText(request.requestNumber!),
|
||||
const SizedBox(width: 12),
|
||||
StatusPill(label: ItServiceRequestStatus.label(request.status)),
|
||||
StatusPill.kind(
|
||||
_isrStatusKind(request.status),
|
||||
label: ItServiceRequestStatus.label(request.status),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'Created ${AppTime.formatDate(request.createdAt)}',
|
||||
@@ -1231,10 +1235,10 @@ class _ItServiceRequestDetailScreenState
|
||||
child: const Icon(Icons.save, size: 14),
|
||||
)
|
||||
: isSaved
|
||||
? const Icon(
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
key: ValueKey('label_saved'),
|
||||
color: Colors.green,
|
||||
key: const ValueKey('label_saved'),
|
||||
color: AppStatusColors.of(context).success.color,
|
||||
size: 14,
|
||||
)
|
||||
: const SizedBox(key: ValueKey('label_idle')),
|
||||
@@ -1584,9 +1588,6 @@ class _ItServiceRequestDetailScreenState
|
||||
final controller = TextEditingController();
|
||||
return AlertDialog(
|
||||
title: const Text('Cancellation Reason'),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
@@ -1836,7 +1837,6 @@ class _StaffPickerDialogState extends State<_StaffPickerDialog> {
|
||||
.toList();
|
||||
return AlertDialog(
|
||||
title: const Text('Select IT Staff'),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
content: SizedBox(
|
||||
width: 350,
|
||||
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 '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/reconnect_overlay.dart';
|
||||
@@ -381,9 +382,6 @@ class _ItServiceRequestsListScreenState
|
||||
builder: (ctx, setDialogState) {
|
||||
return AlertDialog(
|
||||
title: const Text('New IT Service Request'),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 400,
|
||||
child: SingleChildScrollView(
|
||||
@@ -538,6 +536,7 @@ class _StatusSummaryRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
final pending = requests
|
||||
.where((r) => r.status == ItServiceRequestStatus.pendingApproval)
|
||||
.length;
|
||||
@@ -563,7 +562,7 @@ class _StatusSummaryRow extends StatelessWidget {
|
||||
_SummaryChip(
|
||||
label: 'Pending',
|
||||
count: pending,
|
||||
color: cs.tertiary,
|
||||
color: status.warning.color,
|
||||
selected: selectedStatus == ItServiceRequestStatus.pendingApproval,
|
||||
onTap: () => onStatusTap(ItServiceRequestStatus.pendingApproval),
|
||||
),
|
||||
@@ -571,7 +570,7 @@ class _StatusSummaryRow extends StatelessWidget {
|
||||
_SummaryChip(
|
||||
label: 'Scheduled',
|
||||
count: scheduled,
|
||||
color: cs.primary,
|
||||
color: status.info.color,
|
||||
selected: selectedStatus == ItServiceRequestStatus.scheduled,
|
||||
onTap: () => onStatusTap(ItServiceRequestStatus.scheduled),
|
||||
),
|
||||
@@ -587,7 +586,7 @@ class _StatusSummaryRow extends StatelessWidget {
|
||||
_SummaryChip(
|
||||
label: 'Completed',
|
||||
count: completed,
|
||||
color: Colors.green,
|
||||
color: status.success.color,
|
||||
selected: selectedStatus == 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_location.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../providers/network_map/network_devices_provider.dart';
|
||||
import '../../providers/network_map/network_sites_provider.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
@@ -231,11 +232,19 @@ class _NetworkMapDeviceEditScreenState
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
switch (_status) {
|
||||
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:
|
||||
return Icon(Icons.circle, size: 14, color: cs.error);
|
||||
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:
|
||||
return Icon(Icons.circle_outlined, size: 14, color: cs.onSurfaceVariant);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import 'network_device_icon.dart';
|
||||
|
||||
/// 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) {
|
||||
case NetworkDeviceStatus.online:
|
||||
return const Color(0xFF34A853); // Google green — universally understood
|
||||
return s.success.color;
|
||||
case NetworkDeviceStatus.offline:
|
||||
return cs.error;
|
||||
return Theme.of(context).colorScheme.error;
|
||||
case NetworkDeviceStatus.warning:
|
||||
return const Color(0xFFFBBC04); // Amber
|
||||
return s.warning.color;
|
||||
case NetworkDeviceStatus.unknown:
|
||||
return null; // No dot shown
|
||||
}
|
||||
@@ -59,7 +61,7 @@ class DeviceNode extends StatelessWidget {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final accent = _accentForRole(cs, device.role);
|
||||
final statusColor = _statusColor(cs, device.status);
|
||||
final statusColor = _statusColor(context, device.status);
|
||||
|
||||
final bgColor = isSelected
|
||||
? cs.primaryContainer
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/network/network_device.dart';
|
||||
import '../../../models/network/network_link.dart';
|
||||
import '../../../theme/app_surfaces.dart';
|
||||
|
||||
/// Collapsible legend overlay explaining device role border colours and link
|
||||
/// 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
|
||||
Material(
|
||||
color: cs.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
elevation: 2,
|
||||
child: InkWell(
|
||||
onTap: _toggle,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
child: Row(
|
||||
|
||||
@@ -109,7 +109,7 @@ class _RequestTypeChartState extends ConsumerState<RequestTypeChart> {
|
||||
radius: isTouched ? 60 : 50,
|
||||
color: colorFor(i, e.name),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
@@ -240,7 +240,7 @@ class _RequestCategoryChartState extends ConsumerState<RequestCategoryChart> {
|
||||
radius: isTouched ? 60 : 50,
|
||||
color: colorFor(i, e.name),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../providers/reports_provider.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import 'report_card_wrapper.dart';
|
||||
|
||||
/// Donut chart ticket counts per status with hover animation.
|
||||
@@ -84,7 +85,7 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
||||
radius: isTouched ? 60 : 50,
|
||||
color: _ticketStatusColor(context, e.status),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
@@ -116,16 +117,18 @@ class _TicketsByStatusChartState extends ConsumerState<TicketsByStatusChart> {
|
||||
}
|
||||
|
||||
Color _ticketStatusColor(BuildContext context, String status) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final s = AppStatusColors.of(context);
|
||||
switch (status) {
|
||||
case 'critical':
|
||||
return s.danger.color;
|
||||
case 'pending':
|
||||
return colors.tertiary;
|
||||
return s.warning.color;
|
||||
case 'promoted':
|
||||
return colors.secondary;
|
||||
return s.info.color;
|
||||
case 'closed':
|
||||
return colors.primary;
|
||||
return s.success.color;
|
||||
default:
|
||||
return colors.outlineVariant;
|
||||
return s.neutral.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,7 +211,7 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||
radius: isTouched ? 60 : 50,
|
||||
color: _taskStatusColor(context, e.status),
|
||||
borderSide: isTouched
|
||||
? const BorderSide(color: Colors.white, width: 2)
|
||||
? BorderSide(color: Theme.of(context).colorScheme.surface, width: 2)
|
||||
: BorderSide.none,
|
||||
);
|
||||
}).toList(),
|
||||
@@ -240,18 +243,20 @@ class _TasksByStatusChartState extends ConsumerState<TasksByStatusChart> {
|
||||
}
|
||||
|
||||
Color _taskStatusColor(BuildContext context, String status) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final s = AppStatusColors.of(context);
|
||||
switch (status) {
|
||||
case 'critical':
|
||||
return s.danger.color;
|
||||
case 'queued':
|
||||
return colors.surfaceContainerHighest;
|
||||
return s.neutral.color;
|
||||
case 'in_progress':
|
||||
return colors.secondary;
|
||||
return s.info.color;
|
||||
case 'completed':
|
||||
return colors.primary;
|
||||
return s.success.color;
|
||||
case 'cancelled':
|
||||
return colors.error;
|
||||
return s.danger.color;
|
||||
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 '../../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
|
||||
/// face verification. Performs liveness detection and uploads the result.
|
||||
@@ -144,7 +145,11 @@ class _MobileVerificationScreenState
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
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),
|
||||
Text('Verification Complete', style: theme.textTheme.headlineSmall),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/m3_motion.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -67,7 +68,10 @@ class _PermissionsScreenState extends ConsumerState<PermissionsScreen> {
|
||||
title: Text(info.label),
|
||||
subtitle: Text(status?.toString() ?? 'unknown'),
|
||||
trailing: granted
|
||||
? const Icon(Icons.check, color: Colors.green)
|
||||
? Icon(
|
||||
Icons.check,
|
||||
color: AppStatusColors.of(context).success.color,
|
||||
)
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -13,6 +13,8 @@ import '../../providers/teams_provider.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../widgets/m3_card.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/profile_avatar.dart';
|
||||
@@ -231,24 +233,25 @@ class _ItJobChecklistTabState extends ConsumerState<ItJobChecklistTab> {
|
||||
}
|
||||
|
||||
Widget _buildSkeletonTile(BuildContext context, bool isWide) {
|
||||
final bone = Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
return M3Card.outlined(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(width: 60, height: 14, color: Colors.grey),
|
||||
Container(width: 60, height: 14, color: bone),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(height: 14, color: Colors.grey),
|
||||
child: Container(height: 14, color: bone),
|
||||
),
|
||||
if (isWide) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(width: 80, height: 22, color: Colors.grey),
|
||||
Container(width: 80, height: 22, color: bone),
|
||||
const SizedBox(width: 12),
|
||||
Container(width: 120, height: 14, color: Colors.grey),
|
||||
Container(width: 120, height: 14, color: bone),
|
||||
],
|
||||
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(
|
||||
icon: Icons.check_circle_outline,
|
||||
label: 'Submitted',
|
||||
count: submitted,
|
||||
color: cs.primary,
|
||||
bgColor: cs.primaryContainer,
|
||||
color: status.success.onContainer,
|
||||
bgColor: status.success.container,
|
||||
);
|
||||
|
||||
final pendingChip = _StatChip(
|
||||
icon: Icons.pending_outlined,
|
||||
label: 'Pending',
|
||||
count: pending,
|
||||
color: cs.tertiary,
|
||||
bgColor: cs.tertiaryContainer,
|
||||
color: status.warning.onContainer,
|
||||
bgColor: status.warning.container,
|
||||
);
|
||||
|
||||
return M3Card.filled(
|
||||
@@ -399,7 +403,7 @@ class _StatChip extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -878,11 +882,14 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
|
||||
_isChecked ? cs.primaryContainer.withValues(alpha: 0.18) : null;
|
||||
|
||||
// Status badge
|
||||
final statusSwatch = _isChecked
|
||||
? AppStatusColors.of(context).success
|
||||
: AppStatusColors.of(context).warning;
|
||||
final statusBadge = Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: _isChecked ? cs.primaryContainer : cs.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: statusSwatch.container,
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -892,13 +899,13 @@ class _ItJobTileState extends ConsumerState<_ItJobTile> {
|
||||
? Icons.check_circle_outline
|
||||
: Icons.hourglass_empty_outlined,
|
||||
size: 12,
|
||||
color: _isChecked ? cs.primary : cs.tertiary,
|
||||
color: statusSwatch.onContainer,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_isChecked ? 'Submitted' : 'Pending',
|
||||
style: tt.labelSmall?.copyWith(
|
||||
color: _isChecked ? cs.primary : cs.tertiary,
|
||||
color: statusSwatch.onContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -35,6 +35,7 @@ import '../../widgets/office_picker.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/sync_pending_badge.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
@@ -621,9 +622,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
color: AppStatusColors.of(context).warning.color,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
@@ -2686,16 +2687,24 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
final files =
|
||||
_attachments ?? [];
|
||||
if (files.isEmpty) {
|
||||
return const Center(
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(
|
||||
padding:
|
||||
const EdgeInsets.all(
|
||||
16.0,
|
||||
),
|
||||
child: Text(
|
||||
'No attachments yet',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -2722,9 +2731,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(
|
||||
icon: Icon(
|
||||
Icons.delete,
|
||||
color: Colors.red,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.error,
|
||||
),
|
||||
onPressed: () =>
|
||||
_deleteTaskAttachment(
|
||||
@@ -2873,7 +2884,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
BorderRadius.circular(16),
|
||||
BorderRadius.circular(
|
||||
AppSurfaces.of(
|
||||
context,
|
||||
).cardRadius,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -4883,39 +4898,21 @@ class _TaskStatusChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final (icon, bg, fg) = switch (status) {
|
||||
'queued' => (
|
||||
Icons.inbox_rounded,
|
||||
cs.secondaryContainer,
|
||||
cs.onSecondaryContainer,
|
||||
),
|
||||
'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 s = AppStatusColors.of(context);
|
||||
final (IconData icon, StatusSwatch swatch) = switch (status) {
|
||||
'queued' => (Icons.inbox_rounded, s.neutral),
|
||||
'in_progress' => (Icons.play_arrow_rounded, s.info),
|
||||
'completed' => (Icons.check_circle_rounded, s.success),
|
||||
'cancelled' => (Icons.cancel_rounded, s.danger),
|
||||
_ => (Icons.help_outline_rounded, s.neutral),
|
||||
};
|
||||
final bg = swatch.container;
|
||||
final fg = swatch.onContainer;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -29,6 +29,7 @@ import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
@@ -523,10 +524,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
if (task.status == 'completed' &&
|
||||
task.hasIncompleteDetails) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
color: AppStatusColors.of(context).warning.color,
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -620,10 +621,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||
if (task.status == 'completed' &&
|
||||
task.hasIncompleteDetails) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
color: AppStatusColors.of(context).warning.color,
|
||||
),
|
||||
],
|
||||
if (showTyping) ...[
|
||||
@@ -1353,26 +1354,14 @@ class _StatusSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = switch (status) {
|
||||
'critical' => scheme.errorContainer,
|
||||
'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 swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
|
||||
final background = swatch.container;
|
||||
final foreground = swatch.onContainer;
|
||||
final label = status.replaceAll('_', ' ').toUpperCase();
|
||||
|
||||
return Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
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) {
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return 'Unassigned';
|
||||
@@ -1422,21 +1421,9 @@ class _StatusBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = switch (status) {
|
||||
'critical' => scheme.errorContainer,
|
||||
'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,
|
||||
};
|
||||
final swatch = AppStatusColors.of(context).byKind(_taskStatusKind(status));
|
||||
final background = swatch.container;
|
||||
final foreground = swatch.onContainer;
|
||||
|
||||
return Badge(
|
||||
backgroundColor: background,
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
|
||||
class TicketDetailScreen extends ConsumerStatefulWidget {
|
||||
const TicketDetailScreen({super.key, required this.ticketId});
|
||||
@@ -424,7 +425,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).cardRadius,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -467,7 +470,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppSurfaces.of(context).containerRadius,
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
@@ -1357,34 +1362,20 @@ class _TicketStatusChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final (icon, bg, fg) = switch (status) {
|
||||
'pending' => (
|
||||
Icons.hourglass_empty_rounded,
|
||||
cs.secondaryContainer,
|
||||
cs.onSecondaryContainer,
|
||||
),
|
||||
'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 s = AppStatusColors.of(context);
|
||||
final (IconData icon, StatusSwatch swatch) = switch (status) {
|
||||
'pending' => (Icons.hourglass_empty_rounded, s.warning),
|
||||
'promoted' => (Icons.trending_up_rounded, s.info),
|
||||
'closed' => (Icons.lock_rounded, s.success),
|
||||
_ => (Icons.help_outline_rounded, s.neutral),
|
||||
};
|
||||
final bg = swatch.container;
|
||||
final fg = swatch.onContainer;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@@ -23,6 +23,7 @@ import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
@@ -739,26 +740,14 @@ class _StatusSummaryCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = switch (status) {
|
||||
'critical' => scheme.errorContainer,
|
||||
'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 swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
|
||||
final background = swatch.container;
|
||||
final foreground = swatch.onContainer;
|
||||
final label = status.replaceAll('_', ' ').toUpperCase();
|
||||
|
||||
return Material(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
borderRadius: BorderRadius.circular(AppSurfaces.of(context).chipRadius),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
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
|
||||
/// tooltip. Tickets have no human-friendly number.
|
||||
String _shortId(String id) =>
|
||||
@@ -813,21 +812,9 @@ class _StatusBadge extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final background = switch (status) {
|
||||
'critical' => scheme.errorContainer,
|
||||
'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,
|
||||
};
|
||||
final swatch = AppStatusColors.of(context).byKind(_ticketStatusKind(status));
|
||||
final background = swatch.container;
|
||||
final foreground = swatch.onContainer;
|
||||
|
||||
return Badge(
|
||||
backgroundColor: background,
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
|
||||
import '../../providers/whereabouts_provider.dart';
|
||||
import '../../providers/workforce_provider.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../theme/app_status_colors.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../utils/app_time.dart';
|
||||
@@ -232,8 +233,10 @@ class _WhereaboutsMap extends StatelessWidget {
|
||||
polygons: [
|
||||
Polygon(
|
||||
points: points,
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderColor: Colors.blue,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.12),
|
||||
borderColor: Theme.of(context).colorScheme.primary,
|
||||
borderStrokeWidth: 2.0,
|
||||
),
|
||||
],
|
||||
@@ -313,11 +316,20 @@ class _StaffLegendPanel extends StatelessWidget {
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
_RoleBadge(color: Colors.blue.shade700, label: 'Admin'),
|
||||
_RoleBadge(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
label: 'Admin',
|
||||
),
|
||||
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),
|
||||
_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
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
final roleColor = _roleColor(profile.role, cs);
|
||||
|
||||
final hasPosition = position != null;
|
||||
@@ -445,30 +458,30 @@ class _StaffLegendTile extends StatelessWidget {
|
||||
? 'In premise (Tracking off) \u00b7 $timeLabel'
|
||||
: 'Outside premise (Tracking off) \u00b7 $timeLabel';
|
||||
statusColor = isStale
|
||||
? Colors.grey
|
||||
: (isInPremise ? Colors.green : Colors.grey);
|
||||
? status.neutral.color
|
||||
: (isInPremise ? status.success.color : status.neutral.color);
|
||||
} else if (inferredInPremise) {
|
||||
statusText = 'In premise (Checked in)';
|
||||
statusColor = Colors.green;
|
||||
statusColor = status.success.color;
|
||||
} else {
|
||||
statusText = 'Tracking off';
|
||||
statusColor = Colors.grey;
|
||||
statusColor = status.neutral.color;
|
||||
}
|
||||
} else if (isStale && hasPosition) {
|
||||
final timeLabel = _timeAgo(position!.updatedAt);
|
||||
statusText = isInPremise
|
||||
? 'Last seen in premise \u00b7 $timeLabel'
|
||||
: 'Last seen outside premise \u00b7 $timeLabel';
|
||||
statusColor = Colors.grey;
|
||||
statusColor = status.neutral.color;
|
||||
} else if (isActive) {
|
||||
statusText = 'In premise \u00b7 ${_timeAgo(position!.updatedAt)}';
|
||||
statusColor = Colors.green;
|
||||
statusColor = status.success.color;
|
||||
} else if (hasPosition) {
|
||||
statusText = 'Outside premise \u00b7 ${_timeAgo(position!.updatedAt)}';
|
||||
statusColor = Colors.grey;
|
||||
statusColor = status.neutral.color;
|
||||
} else {
|
||||
statusText = 'No location data';
|
||||
statusColor = Colors.grey;
|
||||
statusColor = status.neutral.color;
|
||||
}
|
||||
|
||||
final IconData statusIcon;
|
||||
|
||||
@@ -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
@@ -1,10 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M3 Expressive surface tokens.
|
||||
/// Cyber/IT surface tokens.
|
||||
///
|
||||
/// Cards now use **tonal elevation** (color tints) instead of drop-shadows.
|
||||
/// Large containers adopt the M3 standard 28 dp corner radius; compact items
|
||||
/// use 16 dp; small chips/badges use 12 dp.
|
||||
/// Cards use **tonal elevation** (color tints) plus a hairline outline instead
|
||||
/// of drop-shadows. The shape language is tightened to a precise 12–16 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
|
||||
class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||
const AppSurfaces({
|
||||
@@ -15,19 +16,19 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||
required this.chipRadius,
|
||||
});
|
||||
|
||||
/// Standard card radius – 16 dp (M3 medium shape).
|
||||
/// Standard card radius – 12 dp (cyber/IT medium shape).
|
||||
final double cardRadius;
|
||||
|
||||
/// Compact card radius for dense list tiles – 12 dp.
|
||||
/// Compact card radius for dense list tiles – 10 dp.
|
||||
final double compactCardRadius;
|
||||
|
||||
/// Large container radius – 28 dp (M3 Expressive).
|
||||
/// Large container radius – 16 dp.
|
||||
final double containerRadius;
|
||||
|
||||
/// Dialog / bottom-sheet radius – 28 dp.
|
||||
/// Dialog / bottom-sheet radius – 20 dp.
|
||||
final double dialogRadius;
|
||||
|
||||
/// Chip / badge radius – 12 dp.
|
||||
/// Chip / badge radius – 8 dp.
|
||||
final double chipRadius;
|
||||
|
||||
// convenience shapes
|
||||
@@ -46,11 +47,11 @@ class AppSurfaces extends ThemeExtension<AppSurfaces> {
|
||||
final ext = Theme.of(context).extension<AppSurfaces>();
|
||||
return ext ??
|
||||
const AppSurfaces(
|
||||
cardRadius: 16,
|
||||
compactCardRadius: 12,
|
||||
containerRadius: 28,
|
||||
dialogRadius: 28,
|
||||
chipRadius: 12,
|
||||
cardRadius: 12,
|
||||
compactCardRadius: 10,
|
||||
containerRadius: 16,
|
||||
dialogRadius: 20,
|
||||
chipRadius: 8,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+187
-91
@@ -3,57 +3,140 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_typography.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:
|
||||
/// * Cards use **tonal elevation** (surfaceTint color overlays) instead of
|
||||
/// drop-shadows, giving surfaces an organic, seed-tinted look.
|
||||
/// * Large containers use the M3 standard **28 dp** corner radius.
|
||||
/// * Buttons follow the M3 hierarchy: FilledButton (primary), Tonal, Elevated,
|
||||
/// Outlined, and Text.
|
||||
/// * NavigationBar / NavigationRail use pill-shaped indicators with the
|
||||
/// secondary-container tonal color.
|
||||
/// * Spring-physics inspired durations: transitions default to 400 ms with an
|
||||
/// emphasized easing curve.
|
||||
/// Identity:
|
||||
/// * Deep slate surfaces (dark) / cool near-white (light) with an electric
|
||||
/// **azure** primary, **teal** secondary and **violet** tertiary.
|
||||
/// * Cards use a flat tonal fill plus a hairline outline (no drop shadow) for a
|
||||
/// precise, technical feel.
|
||||
/// * A tightened 12–16 dp shape language (see [AppSurfaces]).
|
||||
/// * Monospace ([AppMonoText]) reserved for technical data — IDs, IPs, ports,
|
||||
/// timestamps — and chip labels.
|
||||
/// * Semantic status tokens ([AppStatusColors]) drive all status/priority color.
|
||||
class AppTheme {
|
||||
/// The seed color drives M3's entire tonal palette generation.
|
||||
static const Color _seed = Color(0xFF4A6FA5);
|
||||
const AppTheme._();
|
||||
|
||||
/// 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
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData light() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
return _apply(
|
||||
ThemeData(colorScheme: _lightScheme(), useMaterial3: true),
|
||||
AppStatusColors.light,
|
||||
);
|
||||
|
||||
return _apply(base);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// DARK
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData dark() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _seed,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
return _apply(
|
||||
ThemeData(colorScheme: _darkScheme(), useMaterial3: true),
|
||||
AppStatusColors.dark,
|
||||
);
|
||||
|
||||
return _apply(base);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// SHARED BUILDER
|
||||
// ────────────────────────────────────────────────────────────
|
||||
static ThemeData _apply(ThemeData base) {
|
||||
static ThemeData _apply(ThemeData base, AppStatusColors status) {
|
||||
final cs = base.colorScheme;
|
||||
final isDark = cs.brightness == Brightness.dark;
|
||||
|
||||
@@ -68,18 +151,17 @@ class AppTheme {
|
||||
const TextStyle(letterSpacing: 0.2),
|
||||
);
|
||||
|
||||
const surfaces = AppSurfaces(
|
||||
cardRadius: 16,
|
||||
compactCardRadius: 12,
|
||||
containerRadius: 28,
|
||||
dialogRadius: 28,
|
||||
chipRadius: 12,
|
||||
);
|
||||
final surfaces = _surfaces;
|
||||
|
||||
// Card fill: flat tonal surface + hairline outline (cyber "panel" look).
|
||||
final cardColor = isDark ? cs.surfaceContainer : cs.surfaceContainerLowest;
|
||||
|
||||
return base.copyWith(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: isDark ? cs.surface : cs.surfaceContainerLowest,
|
||||
extensions: [mono, surfaces],
|
||||
scaffoldBackgroundColor: isDark
|
||||
? const Color(0xFF0B0F14)
|
||||
: cs.surfaceContainerLowest,
|
||||
extensions: [mono, surfaces, status],
|
||||
|
||||
// ── AppBar ──────────────────────────────────────────────
|
||||
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(
|
||||
color: isDark ? cs.surfaceContainer : cs.surfaceContainerLow,
|
||||
elevation: 1,
|
||||
color: cardColor,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
side: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Chips ───────────────────────────────────────────────
|
||||
// ── Chips — mono label, tight radius ────────────────────
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
side: BorderSide.none,
|
||||
labelStyle: textTheme.labelSmall,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
side: BorderSide(color: cs.outlineVariant),
|
||||
labelStyle: mono.label,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.chipRadius),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Dividers ────────────────────────────────────────────
|
||||
@@ -123,17 +210,17 @@ class AppTheme {
|
||||
// ── Input Fields ────────────────────────────────────────
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerLow,
|
||||
fillColor: isDark ? cs.surfaceContainerLow : cs.surfaceContainer,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
borderSide: BorderSide(color: cs.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
borderSide: BorderSide(color: cs.primary, width: 2),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@@ -142,87 +229,84 @@ class AppTheme {
|
||||
),
|
||||
),
|
||||
|
||||
// ── Buttons — M3 Expressive hierarchy ───────────────────
|
||||
// ── Buttons — M3 hierarchy, tightened shapes ────────────
|
||||
filledButtonTheme: FilledButtonThemeData(
|
||||
style: FilledButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: cs.surfaceContainerLow,
|
||||
backgroundColor: cs.surfaceContainerHigh,
|
||||
foregroundColor: cs.primary,
|
||||
elevation: 1,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
side: BorderSide(color: cs.outlineVariant),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
side: BorderSide(color: cs.outline),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
textStyle: textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textStyle: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
segmentedButtonTheme: SegmentedButtonThemeData(
|
||||
style: ButtonStyle(
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// ── FAB — M3 Expressive ──────────────────────────────────
|
||||
// ── FAB ──────────────────────────────────────────────────
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: cs.primaryContainer,
|
||||
foregroundColor: cs.onPrimaryContainer,
|
||||
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(
|
||||
backgroundColor: cs.surface,
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
tileHeight: 56,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(surfaces.containerRadius),
|
||||
),
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
@@ -238,14 +322,14 @@ class AppTheme {
|
||||
),
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(surfaces.containerRadius),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: cs.surfaceContainer,
|
||||
indicatorColor: cs.secondaryContainer,
|
||||
indicatorShape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
labelTextStyle: WidgetStateProperty.resolveWith((states) {
|
||||
final selected = states.contains(WidgetState.selected);
|
||||
@@ -260,15 +344,19 @@ class AppTheme {
|
||||
|
||||
// ── List Tiles ──────────────────────────────────────────
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.cardRadius),
|
||||
),
|
||||
tileColor: Colors.transparent,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
|
||||
// ── Dialogs ─────────────────────────────────────────────
|
||||
dialogTheme: DialogThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.dialogRadius),
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
backgroundColor: isDark
|
||||
? cs.surfaceContainerHigh
|
||||
: cs.surfaceContainerLowest,
|
||||
@@ -276,20 +364,24 @@ class AppTheme {
|
||||
|
||||
// ── Bottom Sheets ───────────────────────────────────────
|
||||
bottomSheetTheme: BottomSheetThemeData(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(
|
||||
top: Radius.circular(surfaces.dialogRadius),
|
||||
),
|
||||
),
|
||||
backgroundColor: isDark
|
||||
? cs.surfaceContainerHigh
|
||||
: cs.surfaceContainerLowest,
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
showDragHandle: true,
|
||||
),
|
||||
|
||||
// ── Snackbar ────────────────────────────────────────────
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Search Bar ──────────────────────────────────────────
|
||||
@@ -297,7 +389,9 @@ class AppTheme {
|
||||
elevation: WidgetStateProperty.all(0),
|
||||
backgroundColor: WidgetStateProperty.all(cs.surfaceContainerHigh),
|
||||
shape: WidgetStateProperty.all(
|
||||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.containerRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -305,7 +399,7 @@ class AppTheme {
|
||||
tooltipTheme: TooltipThemeData(
|
||||
decoration: BoxDecoration(
|
||||
color: cs.inverseSurface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(surfaces.chipRadius),
|
||||
),
|
||||
textStyle: textTheme.bodySmall?.copyWith(color: cs.onInverseSurface),
|
||||
),
|
||||
@@ -323,8 +417,10 @@ class AppTheme {
|
||||
|
||||
// ── PopupMenu ───────────────────────────────────────────
|
||||
popupMenuTheme: PopupMenuThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
surfaceTintColor: cs.surfaceTint,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(surfaces.compactCardRadius),
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
color: isDark ? cs.surfaceContainerHigh : cs.surfaceContainerLowest,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../providers/attendance_provider.dart';
|
||||
import '../providers/profile_provider.dart';
|
||||
import '../services/face_verification.dart' as face;
|
||||
import '../services/image_compress_service.dart';
|
||||
import '../theme/app_status_colors.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import '../widgets/qr_verification_dialog.dart';
|
||||
|
||||
@@ -302,7 +303,7 @@ class _FaceVerificationOverlayState
|
||||
textAlign: TextAlign.center,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
color: _phase == _Phase.success
|
||||
? Colors.green
|
||||
? AppStatusColors.of(context).success.color
|
||||
: _phase == _Phase.failed
|
||||
? colors.error
|
||||
: _phase == _Phase.saving
|
||||
@@ -337,7 +338,7 @@ class _FaceVerificationOverlayState
|
||||
|
||||
final Color ringColor;
|
||||
if (isSuccess) {
|
||||
ringColor = Colors.green;
|
||||
ringColor = AppStatusColors.of(context).success.color;
|
||||
} else if (isFailed) {
|
||||
ringColor = colors.error;
|
||||
} else if (isSaving) {
|
||||
@@ -461,7 +462,7 @@ class _FaceVerificationOverlayState
|
||||
|
||||
Color dotColor;
|
||||
if (isSuccess_) {
|
||||
dotColor = Colors.green;
|
||||
dotColor = AppStatusColors.of(context).success.color;
|
||||
} else if (isDone) {
|
||||
dotColor = colors.error.withValues(alpha: 0.5);
|
||||
} else if (isCurrent) {
|
||||
|
||||
+52
-43
@@ -1,26 +1,27 @@
|
||||
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:
|
||||
/// - [M3Card.elevated] — default, uses tonal surface tint (no hard shadow).
|
||||
/// - [M3Card.filled] — uses surfaceContainerHighest for emphasis.
|
||||
/// - [M3Card.outlined] — transparent fill with a subtle outline border.
|
||||
|
||||
/// - [M3Card.elevated] — default panel: tonal fill + hairline outline (flat).
|
||||
/// - [M3Card.filled] — high-emphasis fill (caller passes [color]); no border.
|
||||
/// - [M3Card.outlined] — transparent fill with a hairline outline border.
|
||||
class M3Card extends StatelessWidget {
|
||||
const M3Card._({
|
||||
const M3Card._(
|
||||
this._variant, {
|
||||
required this.child,
|
||||
this.color,
|
||||
this.elevation,
|
||||
this.shadowColor,
|
||||
this.surfaceTintColor,
|
||||
this.shape,
|
||||
this.margin,
|
||||
this.clipBehavior = Clip.none,
|
||||
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).
|
||||
factory M3Card.elevated({
|
||||
required Widget child,
|
||||
@@ -31,9 +32,8 @@ class M3Card extends StatelessWidget {
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
_M3CardVariant.elevated,
|
||||
color: color,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.transparent,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
clipBehavior: clipBehavior,
|
||||
@@ -42,8 +42,8 @@ class M3Card extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Filled card — uses surfaceContainerHighest for high emphasis.
|
||||
/// Best for summary cards, status counts, callout panels.
|
||||
/// Filled card — a high-emphasis fill (caller passes a semantic [color] or a
|
||||
/// surfaceContainer tone). No border. Best for summary/callout panels.
|
||||
factory M3Card.filled({
|
||||
required Widget child,
|
||||
Color? color,
|
||||
@@ -53,10 +53,8 @@ class M3Card extends StatelessWidget {
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
color: color, // caller passes surfaceContainerHighest or semantic color
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
_M3CardVariant.filled,
|
||||
color: color,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
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.
|
||||
factory M3Card.outlined({
|
||||
required Widget child,
|
||||
@@ -76,10 +74,8 @@ class M3Card extends StatelessWidget {
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return M3Card._(
|
||||
_M3CardVariant.outlined,
|
||||
color: color,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
clipBehavior: clipBehavior,
|
||||
@@ -88,11 +84,9 @@ class M3Card extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final _M3CardVariant _variant;
|
||||
final Widget child;
|
||||
final Color? color;
|
||||
final double? elevation;
|
||||
final Color? shadowColor;
|
||||
final Color? surfaceTintColor;
|
||||
final ShapeBorder? shape;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
final Clip clipBehavior;
|
||||
@@ -101,36 +95,51 @@ class M3Card extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 =
|
||||
shape ??
|
||||
(elevation == 0 && surfaceTintColor == Colors.transparent
|
||||
? RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: (shadowColor == Colors.transparent && color == null)
|
||||
? BorderSide(color: cs.outlineVariant)
|
||||
: BorderSide.none,
|
||||
)
|
||||
: null);
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
side: bordered
|
||||
? BorderSide(color: cs.outlineVariant)
|
||||
: BorderSide.none,
|
||||
);
|
||||
|
||||
final card = Card(
|
||||
color: color,
|
||||
elevation: elevation,
|
||||
shadowColor: shadowColor,
|
||||
surfaceTintColor: surfaceTintColor,
|
||||
return Card(
|
||||
color: resolvedColor,
|
||||
elevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: resolvedShape,
|
||||
margin: margin ?? EdgeInsets.zero,
|
||||
clipBehavior: clipBehavior,
|
||||
clipBehavior: onTap != null && clipBehavior == Clip.none
|
||||
? Clip.antiAlias
|
||||
: clipBehavior,
|
||||
child: onTap != null
|
||||
? InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
child: child,
|
||||
)
|
||||
: child,
|
||||
);
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// M3 Expressive status pill — uses tonal container colors with a
|
||||
/// smooth, spring-physics-inspired animation.
|
||||
import '../theme/app_status_colors.dart';
|
||||
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 {
|
||||
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 bool isEmphasized;
|
||||
final StatusKind? kind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// isEmphasized uses primaryContainer (higher tonal weight) to visually
|
||||
// distinguish high-priority statuses from routine ones.
|
||||
final background = isEmphasized
|
||||
? scheme.primaryContainer
|
||||
: scheme.tertiaryContainer;
|
||||
final foreground = isEmphasized
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onTertiaryContainer;
|
||||
final radius = AppSurfaces.of(context).chipRadius;
|
||||
|
||||
late final Color background;
|
||||
late final Color foreground;
|
||||
if (kind != null) {
|
||||
final swatch = AppStatusColors.of(context).byKind(kind!);
|
||||
background = swatch.container;
|
||||
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(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
child: Text(
|
||||
label.toUpperCase(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/connectivity_provider.dart';
|
||||
import '../theme/app_status_colors.dart';
|
||||
|
||||
/// Two-state "this item is not yet synced" indicator.
|
||||
///
|
||||
@@ -38,6 +39,7 @@ class SyncPendingBadge extends ConsumerWidget {
|
||||
|
||||
final isOnline = ref.watch(isOnlineProvider);
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
|
||||
final Color bg;
|
||||
final Color fg;
|
||||
@@ -53,9 +55,9 @@ class SyncPendingBadge extends ConsumerWidget {
|
||||
text = label ?? 'Syncing…';
|
||||
spin = true;
|
||||
} else {
|
||||
// Queued offline: amber, static.
|
||||
bg = Colors.amber.shade100;
|
||||
fg = Colors.amber.shade900;
|
||||
// Queued offline: warning tone, static.
|
||||
bg = status.warning.container;
|
||||
fg = status.warning.onContainer;
|
||||
icon = Icons.sync;
|
||||
text = label ?? 'Pending sync';
|
||||
spin = false;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user