Enhanced material design 3 implementation

This commit is contained in:
Marc Rejohn Castillano 2026-03-20 15:15:38 +08:00
parent 27ebb89052
commit 74197c525d
26 changed files with 1345 additions and 515 deletions

View File

@ -28,15 +28,22 @@ final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) return Stream.value(const <AttendanceLog>[]);
// Use .select() so the stream is only recreated when the user id or role
// actually changes (not on avatar/name edits, etc.).
final profileId = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.id),
);
final profileRole = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.role),
);
if (profileId == null) return Stream.value(const <AttendanceLog>[]);
final hasFullAccess =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff';
profileRole == 'admin' ||
profileRole == 'programmer' ||
profileRole == 'dispatcher' ||
profileRole == 'it_staff';
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
stream: hasFullAccess
@ -47,14 +54,14 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
: client
.from('attendance_logs')
.stream(primaryKey: ['id'])
.eq('user_id', profile.id)
.eq('user_id', profileId)
.order('check_in_at', ascending: false),
onPollData: () async {
final query = client.from('attendance_logs').select();
final data = hasFullAccess
? await query.order('check_in_at', ascending: false)
: await query
.eq('user_id', profile.id)
.eq('user_id', profileId)
.order('check_in_at', ascending: false);
return data.map(AttendanceLog.fromMap).toList();
},

View File

@ -26,9 +26,11 @@ final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
// Only recreate stream when user id changes (not on other profile edits).
final profileId = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.id),
);
if (profileId == null) {
return Stream.value(const <DutySchedule>[]);
}
@ -95,16 +97,21 @@ final dutySchedulesForUserProvider =
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull;
if (profile == null) {
// Only recreate stream when user id or role changes.
final profileId = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.id),
);
final profileRole = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.role),
);
if (profileId == null) {
return Stream.value(const <SwapRequest>[]);
}
final isAdmin =
profile.role == 'admin' ||
profile.role == 'programmer' ||
profile.role == 'dispatcher';
profileRole == 'admin' ||
profileRole == 'programmer' ||
profileRole == 'dispatcher';
final wrapper = StreamRecoveryWrapper<SwapRequest>(
stream: isAdmin
@ -135,7 +142,7 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
// either party. admins still see "admin_review" rows so they can act on
// escalated cases.
return result.data.where((row) {
if (!(row.requesterId == profile.id || row.recipientId == profile.id)) {
if (!(row.requesterId == profileId || row.recipientId == profileId)) {
return false;
}
// only keep pending and admin_review statuses

View File

@ -11,6 +11,7 @@ import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../../services/ai_service.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/gemini_animated_text_field.dart';
/// A simple admin-only web page allowing the upload of a new APK and the
@ -478,12 +479,19 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
}
return Scaffold(
appBar: AppBar(title: const Text('APK Update Uploader')),
body: Center(
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'App Update',
subtitle: 'Upload and manage APK releases',
),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
@ -899,6 +907,9 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
),
),
),
),
],
),
);
}
}

View File

@ -6,6 +6,8 @@ import '../../models/office.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../providers/services_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
@ -39,24 +41,42 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
maxWidth: double.infinity,
child: !isAdmin
? const Center(child: Text('Admin access required.'))
: officesAsync.when(
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Office Management',
subtitle: 'Create and manage office locations',
),
Expanded(
child: officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Center(child: Text('No offices found.'));
return const AppEmptyView(
icon: Icons.apartment_outlined,
title: 'No offices yet',
subtitle:
'Create an office to start assigning users and schedules.',
);
}
final query = _searchController.text.trim().toLowerCase();
final query =
_searchController.text.trim().toLowerCase();
final filteredOffices = query.isEmpty
? offices
: offices
.where(
(office) =>
office.name.toLowerCase().contains(query) ||
office.id.toLowerCase().contains(query),
office.name
.toLowerCase()
.contains(query) ||
office.id
.toLowerCase()
.contains(query),
)
.toList();
final listBody = TasQAdaptiveList<Office>(
return TasQAdaptiveList<Office>(
items: filteredOffices,
filterHeader: SizedBox(
width: 320,
@ -73,24 +93,30 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
TasQColumn<Office>(
header: 'Office ID',
technical: true,
cellBuilder: (context, office) => Text(office.id),
cellBuilder: (context, office) =>
Text(office.id),
),
TasQColumn<Office>(
header: 'Office Name',
cellBuilder: (context, office) => Text(office.name),
cellBuilder: (context, office) =>
Text(office.name),
),
],
rowActions: (office) => [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit),
onPressed: () =>
_showOfficeDialog(context, ref, office: office),
onPressed: () => _showOfficeDialog(
context,
ref,
office: office,
),
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context, ref, office),
onPressed: () =>
_confirmDelete(context, ref, office),
),
],
mobileTileBuilder: (context, office, actions) {
@ -98,7 +124,8 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: const Icon(Icons.apartment_outlined),
leading:
const Icon(Icons.apartment_outlined),
title: Text(office.name),
subtitle: MonoText('ID ${office.id}'),
trailing: Wrap(spacing: 8, children: actions),
@ -106,7 +133,6 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
);
},
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(officesQueryProvider.notifier).state =
const OfficeQuery(offset: 0, limit: 50);
},
@ -117,36 +143,16 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
},
isLoading: false,
);
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: Alignment.center,
child: Text(
'Office Management',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
),
Expanded(child: listBody),
],
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load offices: $error')),
error: (error, _) => AppErrorView(
error: error,
onRetry: () => ref.invalidate(officesProvider),
),
),
),
],
),
),
if (isAdmin)

View File

@ -14,6 +14,8 @@ import '../../theme/app_surfaces.dart';
import '../../providers/user_offices_provider.dart';
import '../../utils/app_time.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart';
@ -105,7 +107,14 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
assignmentsAsync.error ??
messagesAsync.error ??
'Unknown error';
return Center(child: Text('Failed to load data: $error'));
return AppErrorView(
error: error,
onRetry: () {
ref.invalidate(profilesProvider);
ref.invalidate(officesProvider);
ref.invalidate(userOfficesProvider);
},
);
}
final profiles = profilesAsync.valueOrNull ?? [];
@ -124,7 +133,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
}
if (profiles.isEmpty) {
return const Center(child: Text('No users found.'));
return const AppEmptyView(
icon: Icons.people_outline,
title: 'No users found',
subtitle: 'Users who sign up will appear here.',
);
}
final query = _searchController.text.trim().toLowerCase();
@ -269,26 +282,16 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
isLoading: false,
);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Column(
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
alignment: Alignment.center,
child: Text(
'User Management',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
const AppPageHeader(
title: 'User Management',
subtitle: 'Manage user roles and office assignments',
),
),
const SizedBox(height: 16),
Expanded(child: listBody),
],
),
);
}

View File

@ -33,6 +33,7 @@ import '../../utils/snackbar.dart';
import '../../widgets/gemini_animated_text_field.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
class AttendanceScreen extends ConsumerStatefulWidget {
@ -86,20 +87,9 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
: null,
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
child: Row(
children: [
Expanded(
child: Text(
'Attendance',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const AppPageHeader(
title: 'Attendance',
subtitle: 'Check in, logbook, pass slip and leave',
),
TabBar(
controller: _tabController,

View File

@ -28,6 +28,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _isLoading = false;
bool _obscurePassword = true;
bool _hasValidationError = false;
@override
void initState() {
@ -56,7 +57,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
Future<void> _handleEmailSignIn() async {
if (!_formKey.currentState!.validate()) return;
if (!_formKey.currentState!.validate()) {
setState(() {
_hasValidationError = !_hasValidationError;
});
return;
}
setState(() => _hasValidationError = false);
setState(() => _isLoading = true);
final auth = ref.read(authControllerProvider);
@ -151,7 +158,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 32),
// Sign-in card
Card(
M3ErrorShake(
hasError: _hasValidationError,
child: Card(
elevation: 0,
color: cs.surfaceContainerLow,
shape: RoundedRectangleBorder(
@ -245,6 +254,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
),
),
), // M3ErrorShake
const SizedBox(height: 20),
// Divider

View File

@ -38,6 +38,7 @@ import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/app_page_header.dart';
import '../../utils/app_time.dart';
class DashboardMetrics {
@ -732,20 +733,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Dashboard',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
const AppPageHeader(
title: 'Dashboard',
subtitle: 'Live metrics and team activity',
),
const _DashboardStatusBanner(),
...sections,
@ -864,12 +856,34 @@ class _DashboardStatusBanner extends ConsumerWidget {
if (bannerState.startsWith('error:')) {
final errorText = bannerState.substring(6);
final cs = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Material(
color: cs.errorContainer,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 18,
color: cs.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'Dashboard data error: $errorText',
errorText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
color: cs.onErrorContainer,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);

View File

@ -18,6 +18,8 @@ import '../../utils/snackbar.dart';
import '../../widgets/m3_card.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
@ -103,17 +105,20 @@ class _ItServiceRequestsListScreenState
child: Builder(
builder: (context) {
if (requestsAsync.hasError && !requestsAsync.hasValue) {
return Center(
child: Text(
'Failed to load requests: ${requestsAsync.error}',
),
return AppErrorView(
error: requestsAsync.error!,
onRetry: () =>
ref.invalidate(itServiceRequestsProvider),
);
}
final allRequests =
requestsAsync.valueOrNull ?? <ItServiceRequest>[];
if (allRequests.isEmpty && !showSkeleton) {
return const Center(
child: Text('No IT service requests yet.'),
return const AppEmptyView(
icon: Icons.miscellaneous_services_outlined,
title: 'No service requests yet',
subtitle:
'IT service requests submitted by your team will appear here.',
);
}
final offices = officesAsync.valueOrNull ?? <Office>[];
@ -146,6 +151,10 @@ class _ItServiceRequestsListScreenState
return Column(
children: [
const AppPageHeader(
title: 'IT Service Requests',
subtitle: 'Manage and track IT support tickets',
),
// Status summary cards
_StatusSummaryRow(
requests: allRequests,

View File

@ -9,6 +9,8 @@ import '../../providers/notifications_provider.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
@ -58,22 +60,12 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
};
return ResponsiveBody(
child: notificationsAsync.when(
data: (items) {
if (items.isEmpty) {
return const Center(child: Text('No notifications yet.'));
}
return Column(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Text(
'Notifications',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const AppPageHeader(
title: 'Notifications',
subtitle: 'Updates and mentions across tasks and tickets',
),
if (_showBanner && !_dismissed)
Padding(
@ -84,22 +76,28 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
),
actions: [
TextButton(
onPressed: () {
openAppSettings();
},
onPressed: openAppSettings,
child: const Text('Open settings'),
),
TextButton(
onPressed: () {
setState(() => _dismissed = true);
},
onPressed: () => setState(() => _dismissed = true),
child: const Text('Dismiss'),
),
],
),
),
Expanded(
child: ListView.separated(
child: notificationsAsync.when(
data: (items) {
if (items.isEmpty) {
return const AppEmptyView(
icon: Icons.notifications_none_outlined,
title: 'No notifications yet',
subtitle:
"You'll see updates here when something needs your attention.",
);
}
return ListView.separated(
padding: const EdgeInsets.only(bottom: 24),
itemCount: items.length,
separatorBuilder: (context, index) =>
@ -124,7 +122,6 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final title = _notificationTitle(item.type, actorName);
final icon = _notificationIcon(item.type);
// M3 Expressive: compact card shape, no shadow.
return Card(
shape: AppSurfaces.of(context).compactShape,
child: ListTile(
@ -142,10 +139,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
],
),
trailing: item.isUnread
? const Icon(
? Icon(
Icons.circle,
size: 10,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
)
: null,
onTap: () async {
@ -174,14 +171,16 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
),
);
},
),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load notifications: $error')),
error: (error, _) => AppErrorView(
error: error,
onRetry: () => ref.invalidate(notificationsProvider),
),
),
),
],
),
);
}

View File

@ -12,6 +12,7 @@ import '../../services/face_verification.dart' as face;
import '../../widgets/face_verification_overlay.dart';
import '../../widgets/multi_select_picker.dart';
import '../../widgets/qr_verification_dialog.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
import '../../utils/snackbar.dart';
@ -74,12 +75,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
return ResponsiveBody(
child: SingleChildScrollView(
padding: const EdgeInsets.only(top: 16, bottom: 32),
padding: const EdgeInsets.only(bottom: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('My Profile', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 12),
const AppPageHeader(
title: 'My Profile',
subtitle: 'Manage your account and preferences',
),
// Avatar Card
_buildAvatarCard(context, profileAsync),
@ -287,7 +290,9 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Center(
child: Stack(
children: [
CircleAvatar(
Hero(
tag: 'profile-avatar',
child: CircleAvatar(
radius: 56,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
@ -301,6 +306,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
child: Center(child: CircularProgressIndicator()),
@ -368,7 +374,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
children: [
Icon(
hasFace ? Icons.check_circle : Icons.cancel,
color: hasFace ? Colors.green : colors.error,
color: hasFace ? colors.tertiary : colors.error,
),
const SizedBox(width: 8),
Expanded(

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/reports_provider.dart';
import '../../widgets/app_page_header.dart';
import 'report_date_filter.dart';
import 'report_widget_selector.dart';
import 'report_pdf_export.dart';
@ -69,7 +70,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
@override
Widget build(BuildContext context) {
final enabled = ref.watch(reportWidgetToggleProvider);
final theme = Theme.of(context);
return Scaffold(
body: Column(
@ -82,17 +83,10 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
constraints: const BoxConstraints(maxWidth: 1200),
child: Column(
children: [
// Title row
Row(
children: [
Expanded(
child: Text(
'Reports',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
AppPageHeader(
title: 'Reports',
subtitle: 'Analytics and performance insights',
actions: [
FilledButton.icon(
onPressed: _exporting ? null : _exportPdf,
icon: _exporting

View File

@ -28,6 +28,8 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart';
import '../../widgets/gemini_button.dart';
import '../../widgets/gemini_animated_text_field.dart';
@ -156,17 +158,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
builder: (context) {
// Show error only when there is genuinely no data.
if (tasksAsync.hasError && !tasksAsync.hasValue) {
return Center(
child: Text('Failed to load tasks: ${tasksAsync.error}'),
return AppErrorView(
error: tasksAsync.error!,
title: 'Could not load tasks',
onRetry: () => ref.invalidate(tasksProvider),
);
}
final tasks = tasksAsync.valueOrNull ?? <Task>[];
// True empty state data loaded but nothing returned.
if (tasks.isEmpty && !effectiveShowSkeleton) {
return const Center(child: Text('No tasks yet.'));
}
final offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices)
..sort(
@ -479,12 +478,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
),
],
if (hasMention)
const Padding(
padding: EdgeInsets.only(left: 8),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.circle,
size: 10,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
),
),
],
@ -513,17 +512,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Tasks',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
const AppPageHeader(
title: 'Tasks',
subtitle: 'Work items assigned to your team',
),
Expanded(
child: Column(
@ -539,8 +530,28 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
child: TabBarView(
controller: _tabController,
children: [
makeList(myTasks),
makeList(filteredTasks),
myTasks.isEmpty && !effectiveShowSkeleton
? AppEmptyView(
icon: Icons.task_outlined,
title: _hasTaskFilters
? 'No matching tasks'
: 'No tasks assigned to you',
subtitle: _hasTaskFilters
? 'Try adjusting your filters.'
: 'Tasks assigned to you will appear here.',
)
: makeList(myTasks),
filteredTasks.isEmpty && !effectiveShowSkeleton
? AppEmptyView(
icon: Icons.task_alt_outlined,
title: _hasTaskFilters
? 'No matching tasks'
: 'No tasks yet',
subtitle: _hasTaskFilters
? 'Try adjusting your filters.'
: 'Tasks created for your team will appear here.',
)
: makeList(filteredTasks),
],
),
),

View File

@ -12,6 +12,8 @@ import '../../utils/supabase_response.dart';
import 'package:tasq/widgets/multi_select_picker.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../utils/snackbar.dart';
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so
@ -233,30 +235,19 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: Alignment.center,
child: Text(
'Team Management',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
const AppPageHeader(
title: 'IT Staff Teams',
subtitle: 'Manage support teams and assignments',
),
Expanded(child: listBody),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
error: (err, stack) => AppErrorView(
error: err,
onRetry: () => ref.invalidate(teamsProvider),
),
),
floatingActionButton: M3Fab(
onPressed: () => _showTeamDialog(context),

View File

@ -21,6 +21,8 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
class TicketsListScreen extends ConsumerStatefulWidget {
const TicketsListScreen({super.key});
@ -90,6 +92,14 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
builder: (context) {
// Build the list UI immediately so `Skeletonizer` can
// render placeholders while providers are still loading.
if (ticketsAsync.hasError && !ticketsAsync.hasValue) {
return AppErrorView(
error: ticketsAsync.error!,
title: 'Could not load tickets',
onRetry: () => ref.invalidate(ticketsProvider),
);
}
final tickets = ticketsAsync.valueOrNull ?? <Ticket>[];
final officeById = <String, Office>{
for (final office in officesAsync.valueOrNull ?? <Office>[])
@ -205,6 +215,31 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
],
);
if (filteredTickets.isEmpty && !effectiveShowSkeleton) {
final hasFilters = _hasTicketFilters;
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Tickets',
subtitle: 'Support requests and service tickets',
),
Expanded(
child: AppEmptyView(
icon: Icons.confirmation_number_outlined,
title: hasFilters
? 'No matching tickets'
: 'No tickets yet',
subtitle: hasFilters
? 'Try adjusting your filters.'
: 'Tickets submitted by your team will appear here.',
),
),
],
);
}
final listBody = TasQAdaptiveList<Ticket>(
items: filteredTickets,
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
@ -303,12 +338,12 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
),
],
if (hasMention)
const Padding(
padding: EdgeInsets.only(left: 8),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.circle,
size: 10,
color: Colors.red,
color: Theme.of(context).colorScheme.error,
),
),
],
@ -323,17 +358,9 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Tickets',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
const AppPageHeader(
title: 'Tickets',
subtitle: 'Support requests and service tickets',
),
Expanded(child: listBody),
],

View File

@ -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 '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart';
import '../../utils/app_time.dart';
@ -19,12 +20,12 @@ import '../../utils/app_time.dart';
const _trackedRoles = {'admin', 'dispatcher', 'it_staff'};
/// Role color mapping shared between map pins and legend.
Color _roleColor(String? role) {
Color _roleColor(String? role, ColorScheme cs) {
return switch (role) {
'admin' => Colors.blue.shade700,
'it_staff' => Colors.green.shade700,
'dispatcher' => Colors.orange.shade700,
_ => Colors.grey,
'admin' => cs.primary,
'it_staff' => cs.tertiary,
'dispatcher' => cs.secondary,
_ => cs.outline,
};
}
@ -106,16 +107,11 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
return ResponsiveBody(
maxWidth: 1200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Text(
'Whereabouts',
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const AppPageHeader(
title: 'Whereabouts',
subtitle: 'Live staff positions and active check-ins',
),
// Map
Expanded(
@ -182,7 +178,8 @@ class _WhereaboutsMap extends StatelessWidget {
final profile = profileById[pos.userId];
final name = profile?.fullName ?? 'Unknown';
final stale = _isStale(pos.updatedAt);
final pinColor = stale ? Colors.grey : _roleColor(profile?.role);
final cs = Theme.of(context).colorScheme;
final pinColor = stale ? cs.outlineVariant : _roleColor(profile?.role, cs);
return Marker(
point: LatLng(pos.lat, pos.lng),
width: 80,
@ -419,7 +416,7 @@ class _StaffLegendTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final roleColor = _roleColor(profile.role);
final roleColor = _roleColor(profile.role, cs);
final hasPosition = position != null;
final isInPremise = position?.inPremise ?? false;
@ -436,7 +433,7 @@ class _StaffLegendTile extends StatelessWidget {
final effectiveColor = (isActive || inferredInPremise)
? roleColor
: Colors.grey.shade400;
: cs.outlineVariant;
// Build status label
final String statusText;

View File

@ -14,6 +14,8 @@ import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/ramadan_provider.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart';
@ -30,12 +32,21 @@ class WorkforceScreen extends ConsumerWidget {
role == 'admin' || role == 'programmer' || role == 'dispatcher';
return ResponsiveBody(
child: Column(
children: [
const AppPageHeader(
title: 'Workforce',
subtitle: 'Duty schedules and shift management',
),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= 980;
final schedulePanel = _SchedulePanel(isAdmin: isAdmin);
final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin);
final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin);
final generatorPanel = _ScheduleGeneratorPanel(
enabled: isAdmin,
);
if (isWide) {
return Row(
@ -84,6 +95,9 @@ class WorkforceScreen extends ConsumerWidget {
);
},
),
),
],
),
);
}
}
@ -140,7 +154,12 @@ class _SchedulePanel extends ConsumerWidget {
.toList();
if (schedules.isEmpty) {
return const Center(child: Text('No schedules yet.'));
return const AppEmptyView(
icon: Icons.calendar_month_outlined,
title: 'No schedules yet',
subtitle:
'Generated schedules will appear here. Use the Generator tab to create them.',
);
}
final Map<String, Profile> profileById = {
@ -192,6 +211,7 @@ class _SchedulePanel extends ConsumerWidget {
),
isMine: schedule.userId == currentUserId,
isAdmin: isAdmin,
role: profileById[schedule.userId]?.role,
),
),
],
@ -201,8 +221,10 @@ class _SchedulePanel extends ConsumerWidget {
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load schedules: $error')),
error: (error, _) => AppErrorView(
error: error,
onRetry: () => ref.invalidate(dutySchedulesProvider),
),
),
),
],
@ -280,6 +302,7 @@ class _ScheduleTile extends ConsumerWidget {
required this.relieverLabels,
required this.isMine,
required this.isAdmin,
this.role,
});
final DutySchedule schedule;
@ -287,29 +310,27 @@ class _ScheduleTile extends ConsumerWidget {
final List<String> relieverLabels;
final bool isMine;
final bool isAdmin;
final String? role;
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUserId = ref.watch(currentUserIdProvider);
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
final now = AppTime.now();
final isPast = schedule.startTime.isBefore(now);
final hasRequestedSwap = swaps.any(
// Use .select() so this tile only rebuilds when its own swap status changes,
// not every time any swap in the list is updated.
final hasRequestedSwap = ref.watch(
swapRequestsProvider.select(
(async) => (async.valueOrNull ?? const []).any(
(swap) =>
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
),
),
);
final now = AppTime.now();
final isPast = schedule.startTime.isBefore(now);
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
Profile? profile;
try {
profile = profiles.firstWhere((p) => p.id == schedule.userId);
} catch (_) {
profile = null;
}
final role = profile?.role;
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
ShiftTypeConfig? shiftTypeConfig;
@ -889,15 +910,16 @@ class _ScheduleTile extends ConsumerWidget {
}
Color _statusColor(BuildContext context, String status) {
final cs = Theme.of(context).colorScheme;
switch (status) {
case 'arrival':
return Colors.green;
return cs.tertiary;
case 'late':
return Colors.orange;
return cs.secondary;
case 'absent':
return Colors.red;
return cs.error;
default:
return Theme.of(context).colorScheme.onSurfaceVariant;
return cs.onSurfaceVariant;
}
}
}

View File

@ -3,6 +3,15 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// Reduced-motion helper
/// Returns true when the OS/user has requested reduced motion.
///
/// Always query this in the [build] method never in [initState] because
/// the value is read from [MediaQuery] which requires a valid [BuildContext].
bool m3ReducedMotion(BuildContext context) =>
MediaQuery.of(context).disableAnimations;
/// M3 Expressive motion constants and helpers.
///
/// Transitions use spring-physics inspired curves with an emphasized easing
@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State<M3FadeSlideIn>
@override
Widget build(BuildContext context) {
// Skip animation entirely when the OS requests reduced motion.
if (m3ReducedMotion(context)) return widget.child;
return FadeTransition(
opacity: _opacity,
child: SlideTransition(position: _slide, child: widget.child),
@ -514,3 +525,391 @@ Future<T?> m3ShowBottomSheet<T>({
builder: builder,
);
}
//
// M3ShimmerBox animated loading skeleton shimmer
//
/// An animated shimmer placeholder used during loading states.
///
/// The highlight sweeps from left to right at 1.2-second intervals,
/// matching the "loading skeleton" pattern from M3 guidelines.
/// Automatically falls back to a static surface when the OS requests
/// reduced motion.
///
/// ```dart
/// M3ShimmerBox(width: 120, height: 14, borderRadius: BorderRadius.circular(4))
/// ```
class M3ShimmerBox extends StatefulWidget {
const M3ShimmerBox({
super.key,
this.width,
this.height,
this.borderRadius,
this.child,
});
final double? width;
final double? height;
/// Defaults to `BorderRadius.circular(6)` when null.
final BorderRadius? borderRadius;
/// Optional child rendered on top of the shimmer surface.
final Widget? child;
@override
State<M3ShimmerBox> createState() => _M3ShimmerBoxState();
}
class _M3ShimmerBoxState extends State<M3ShimmerBox>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final radius = widget.borderRadius ?? BorderRadius.circular(6);
final baseColor = cs.surfaceContainerHighest;
final highlightColor = cs.surfaceContainerHigh;
if (m3ReducedMotion(context)) {
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(color: baseColor, borderRadius: radius),
child: widget.child,
);
}
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
// Shimmer highlight sweeps from -0.5 1.5 across the widget width.
final t = _ctrl.value;
final dx = -0.5 + t * 2.0;
return Container(
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
borderRadius: radius,
gradient: LinearGradient(
begin: Alignment(dx - 0.5, 0),
end: Alignment(dx + 0.5, 0),
colors: [baseColor, highlightColor, baseColor],
),
),
child: child,
);
},
child: widget.child,
);
}
}
//
// M3PressScale press-to-scale micro-interaction
//
/// Wraps any widget with a subtle scale-down animation on press, giving
/// tactile visual feedback for tappable surfaces (cards, list tiles, etc.).
///
/// Uses [M3Motion.micro] duration with [M3Motion.emphasizedEnter] curve
/// so the press response feels instant but controlled.
///
/// ```dart
/// M3PressScale(
/// onTap: () => context.go('/detail'),
/// child: Card(child: ...),
/// )
/// ```
class M3PressScale extends StatefulWidget {
const M3PressScale({
super.key,
required this.child,
this.onTap,
this.onLongPress,
this.scale = 0.97,
});
final Widget child;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
/// The scale factor applied during a press. Defaults to `0.97`.
final double scale;
@override
State<M3PressScale> createState() => _M3PressScaleState();
}
class _M3PressScaleState extends State<M3PressScale>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late final Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: M3Motion.micro);
_scaleAnim = Tween<double>(begin: 1.0, end: widget.scale).animate(
CurvedAnimation(parent: _ctrl, curve: M3Motion.emphasizedEnter),
);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
void _onDown(TapDownDetails _) => _ctrl.forward();
void _onUp(TapUpDetails _) => _ctrl.reverse();
void _onCancel() => _ctrl.reverse();
@override
Widget build(BuildContext context) {
// Skip animation when the OS requests reduced motion.
if (m3ReducedMotion(context)) {
return GestureDetector(
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: widget.child,
);
}
return GestureDetector(
onTapDown: _onDown,
onTapUp: _onUp,
onTapCancel: _onCancel,
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: ScaleTransition(scale: _scaleAnim, child: widget.child),
);
}
}
//
// M3ErrorShake horizontal shake animation for validation errors
//
/// Plays a brief horizontal shake animation whenever [hasError] transitions
/// from `false` to `true` ideal for wrapping form cards or individual
/// fields to signal a validation failure.
///
/// Automatically skips the animation when the OS requests reduced motion.
///
/// ```dart
/// M3ErrorShake(
/// hasError: _isLoading == false && _errorMessage != null,
/// child: Card(child: Form(...)),
/// )
/// ```
class M3ErrorShake extends StatefulWidget {
const M3ErrorShake({
super.key,
required this.child,
required this.hasError,
});
final Widget child;
/// When this transitions from `false` `true` the shake fires once.
final bool hasError;
@override
State<M3ErrorShake> createState() => _M3ErrorShakeState();
}
class _M3ErrorShakeState extends State<M3ErrorShake>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
bool _prevError = false;
@override
void initState() {
super.initState();
_ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 450),
);
}
@override
void didUpdateWidget(M3ErrorShake oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.hasError && !_prevError) {
_ctrl.forward(from: 0);
}
_prevError = widget.hasError;
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (m3ReducedMotion(context)) return widget.child;
return AnimatedBuilder(
animation: _ctrl,
builder: (context, child) {
// Damped oscillation: amplitude fades as t 1.
final t = _ctrl.value;
final dx = math.sin(t * math.pi * 5) * 8.0 * (1.0 - t);
return Transform.translate(offset: Offset(dx, 0), child: child);
},
child: widget.child,
);
}
}
//
// M3BounceIcon entrance + idle pulse for empty/error states
//
/// Displays an [icon] inside a colored circular badge with:
/// 1. A spring-bounce entrance animation on first build.
/// 2. A slow, gentle idle pulse (scale 1.0 1.06 1.0) that repeats
/// indefinitely to draw attention without being distracting.
///
/// Respects [m3ReducedMotion] both animations are skipped when reduced
/// motion is enabled.
class M3BounceIcon extends StatefulWidget {
const M3BounceIcon({
super.key,
required this.icon,
required this.iconColor,
required this.backgroundColor,
this.size = 72.0,
this.iconSize = 36.0,
});
final IconData icon;
final Color iconColor;
final Color backgroundColor;
final double size;
final double iconSize;
@override
State<M3BounceIcon> createState() => _M3BounceIconState();
}
class _M3BounceIconState extends State<M3BounceIcon>
with TickerProviderStateMixin {
late final AnimationController _entranceCtrl;
late final AnimationController _pulseCtrl;
late final Animation<double> _entrance;
late final Animation<double> _pulse;
@override
void initState() {
super.initState();
// Entrance: scale 0 1.0 with spring overshoot.
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
// Idle pulse: 1.0 1.06 1.0, repeating every 2.5 s.
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2500),
)..repeat(reverse: true);
_pulse = Tween<double>(begin: 1.0, end: 1.06).animate(
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
);
_entranceCtrl.forward();
}
@override
void dispose() {
_entranceCtrl.dispose();
_pulseCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final badge = Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: widget.backgroundColor,
shape: BoxShape.circle,
),
child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor),
);
if (m3ReducedMotion(context)) return badge;
return ScaleTransition(
scale: _entrance,
child: AnimatedBuilder(
animation: _pulse,
builder: (_, child) =>
Transform.scale(scale: _pulse.value, child: child),
child: badge,
),
);
}
}
//
// M3AnimatedCounter smooth number animation
//
/// Animates an integer value from its previous value to [value] using a
/// [TweenAnimationBuilder], producing a smooth counting effect on metric
/// cards and dashboard KPIs.
///
/// ```dart
/// M3AnimatedCounter(
/// value: totalTasks,
/// style: theme.textTheme.headlineMedium,
/// )
/// ```
class M3AnimatedCounter extends StatelessWidget {
const M3AnimatedCounter({
super.key,
required this.value,
this.style,
this.duration = M3Motion.standard,
});
final int value;
final TextStyle? style;
final Duration duration;
@override
Widget build(BuildContext context) {
if (m3ReducedMotion(context)) {
return Text(value.toString(), style: style);
}
return TweenAnimationBuilder<int>(
tween: IntTween(begin: 0, end: value),
duration: duration,
curve: M3Motion.emphasizedEnter,
builder: (_, v, _) => Text(v.toString(), style: style),
);
}
}

View File

@ -114,19 +114,39 @@ void showAwesomeSnackBar(
}
void showSuccessSnackBar(BuildContext context, String message) {
showSuccessSnackBarGlobal(message);
showAwesomeSnackBar(
context,
title: 'Success',
message: message,
snackType: SnackType.success,
);
}
void showErrorSnackBar(BuildContext context, String message) {
showErrorSnackBarGlobal(message);
showAwesomeSnackBar(
context,
title: 'Error',
message: message,
snackType: SnackType.error,
);
}
void showInfoSnackBar(BuildContext context, String message) {
showInfoSnackBarGlobal(message);
showAwesomeSnackBar(
context,
title: 'Info',
message: message,
snackType: SnackType.info,
);
}
void showWarningSnackBar(BuildContext context, String message) {
showWarningSnackBarGlobal(message);
showAwesomeSnackBar(
context,
title: 'Warning',
message: message,
snackType: SnackType.warning,
);
}
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.

View File

@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// Standardized M3 Expressive page header with animated entrance.
///
/// Replaces the ad-hoc `Padding(Align(Text(...)))` pattern found across
/// screens. Provides:
/// * `headlineSmall` typography correct M3 size for a top-level page title.
/// * [M3FadeSlideIn] entrance animation 400 ms with emphasized easing.
/// * Optional [subtitle] in `bodyMedium` / `onSurfaceVariant`.
/// * Optional [actions] row rendered at the trailing end.
/// * Responsive alignment: left-aligned on desktop (600 dp), centered on
/// mobile to match the existing navigation-bar-centric layout.
class AppPageHeader extends StatelessWidget {
const AppPageHeader({
super.key,
required this.title,
this.subtitle,
this.actions,
this.padding = const EdgeInsets.only(top: 20, bottom: 12),
});
final String title;
final String? subtitle;
/// Optional trailing action widgets (e.g. filter chip, icon button).
final List<Widget>? actions;
/// Vertical padding around the header. Horizontal padding is handled by
/// the parent [ResponsiveBody] so only top/bottom should be set here.
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final tt = Theme.of(context).textTheme;
final cs = Theme.of(context).colorScheme;
final titleWidget = Text(
title,
style: tt.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: -0.3,
color: cs.onSurface,
),
textAlign: TextAlign.center,
);
final subtitleWidget = subtitle == null
? null
: Text(
subtitle!,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
);
final hasActions = actions != null && actions!.isNotEmpty;
Widget content;
if (!hasActions) {
content = Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
if (subtitleWidget != null) ...[
const SizedBox(height: 4),
subtitleWidget,
],
],
);
} else {
// With actions centered title/subtitle, actions sit to the right.
content = Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
titleWidget,
if (subtitleWidget != null) ...[
const SizedBox(height: 4),
subtitleWidget,
],
],
),
),
const SizedBox(width: 8),
Row(mainAxisSize: MainAxisSize.min, children: actions!),
],
);
}
return M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(padding: padding, child: content),
);
}
}

View File

@ -84,6 +84,7 @@ class AppScaffold extends ConsumerWidget {
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
const SizedBox(width: 8),
Text(displayName),
@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
fullName: displayName,
avatarUrl: avatarUrl,
radius: 16,
heroTag: 'profile-avatar',
),
),
IconButton(
@ -273,18 +275,41 @@ class _NotificationBell extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme;
return IconButton(
tooltip: 'Notifications',
onPressed: () => context.go('/notifications'),
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications),
if (unreadCount > 0)
const Positioned(
right: -2,
top: -2,
child: Icon(Icons.circle, size: 10, color: Colors.red),
const Icon(Icons.notifications_outlined),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: unreadCount > 0
? Positioned(
key: const ValueKey('badge'),
right: -3,
top: -3,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: cs.error,
shape: BoxShape.circle,
border: Border.all(
color: cs.surface,
width: 1.5,
),
),
),
)
: const SizedBox.shrink(key: ValueKey('no-badge')),
),
],
),
@ -422,24 +447,12 @@ List<NavSection> _buildSections(String role) {
icon: Icons.apartment_outlined,
selectedIcon: Icons.apartment,
),
NavItem(
label: 'Geofence test',
route: '/settings/geofence-test',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
),
NavItem(
label: 'IT Staff Teams',
route: '/settings/teams',
icon: Icons.groups_2_outlined,
selectedIcon: Icons.groups_2,
),
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
),
if (kIsWeb) ...[
NavItem(
label: 'App Update',
@ -459,19 +472,16 @@ List<NavSection> _buildSections(String role) {
];
}
// non-admin users still get a simple Settings section containing only
// permissions. this keeps the screen accessible without exposing the
// administrative management screens.
return [
NavSection(label: 'Operations', items: mainItems),
NavSection(
label: 'Settings',
items: [
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
label: 'Logout',
route: '',
icon: Icons.logout,
isLogout: true,
),
],
),

View File

@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import '../theme/m3_motion.dart';
/// A centered error state with an icon, title, human-readable message and an
/// optional retry button.
///
/// Usage:
/// ```dart
/// if (async.hasError && !async.hasValue) {
/// return AppErrorView(
/// error: async.error!,
/// onRetry: () => ref.invalidate(myProvider),
/// );
/// }
/// ```
class AppErrorView extends StatelessWidget {
const AppErrorView({
super.key,
required this.error,
this.title = 'Something went wrong',
this.onRetry,
});
final Object error;
/// Short title shown above the error message.
final String title;
/// When provided, a "Try again" button is rendered below the message.
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
final message = _humanise(error);
return Center(
child: M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
M3BounceIcon(
icon: Icons.error_outline_rounded,
iconColor: cs.onErrorContainer,
backgroundColor: cs.errorContainer,
),
const SizedBox(height: 20),
Text(
title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w700),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
message,
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
if (onRetry != null) ...[
const SizedBox(height: 24),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh_rounded),
label: const Text('Try again'),
),
],
],
),
),
),
);
}
/// Strips common Dart exception prefixes so the user sees a clean message.
static String _humanise(Object error) {
var text = error.toString();
const prefixes = ['Exception: ', 'Error: ', 'FormatException: '];
for (final p in prefixes) {
if (text.startsWith(p)) {
text = text.substring(p.length);
break;
}
}
return text.isEmpty ? 'An unexpected error occurred.' : text;
}
}
/// A centered empty-state view with an icon, title and optional subtitle.
///
/// Usage:
/// ```dart
/// if (items.isEmpty && !loading) {
/// return const AppEmptyView(
/// icon: Icons.task_outlined,
/// title: 'No tasks yet',
/// subtitle: 'Tasks assigned to you will appear here.',
/// );
/// }
/// ```
class AppEmptyView extends StatelessWidget {
const AppEmptyView({
super.key,
this.icon = Icons.inbox_outlined,
required this.title,
this.subtitle,
this.action,
});
final IconData icon;
final String title;
final String? subtitle;
/// Optional call-to-action placed below the subtitle.
final Widget? action;
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final tt = Theme.of(context).textTheme;
return Center(
child: M3FadeSlideIn(
duration: M3Motion.standard,
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
M3BounceIcon(
icon: icon,
iconColor: cs.onSurfaceVariant,
backgroundColor: cs.surfaceContainerHighest,
),
const SizedBox(height: 20),
Text(
title,
style: tt.titleMedium?.copyWith(fontWeight: FontWeight.w600),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 8),
Text(
subtitle!,
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
textAlign: TextAlign.center,
),
],
if (action != null) ...[
const SizedBox(height: 20),
action!,
],
],
),
),
),
);
}
}

View File

@ -3,18 +3,25 @@ import 'package:flutter/material.dart';
/// Native Flutter profile avatar that displays either:
/// 1. User's avatar image URL (if provided)
/// 2. Initials derived from full name (fallback)
///
/// Pass [heroTag] to participate in a Hero transition to/from a destination
/// that uses the same tag (e.g., the profile screen's large avatar).
class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({
super.key,
required this.fullName,
this.avatarUrl,
this.radius = 18,
this.heroTag,
});
final String fullName;
final String? avatarUrl;
final double radius;
/// When non-null, wraps the avatar in a [Hero] with this tag.
final Object? heroTag;
String _getInitials() {
final trimmed = fullName.trim();
if (trimmed.isEmpty) return 'U';
@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
}
Color _getInitialsColor(String initials) {
// Generate a deterministic color based on initials
/// Returns a (background, foreground) pair from the M3 tonal palette.
///
/// Uses a deterministic hash of the initials to cycle through the scheme's
/// semantic container colors so every avatar is theme-aware and accessible.
(Color, Color) _getTonalColors(String initials, ColorScheme cs) {
final hash =
initials.codeUnitAt(0) +
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
final colors = [
Colors.red,
Colors.pink,
Colors.purple,
Colors.deepPurple,
Colors.indigo,
Colors.blue,
Colors.lightBlue,
Colors.cyan,
Colors.teal,
Colors.green,
Colors.lightGreen,
Colors.orange,
Colors.deepOrange,
Colors.brown,
// Six M3-compliant container pairs (background / on-color text).
final pairs = [
(cs.primaryContainer, cs.onPrimaryContainer),
(cs.secondaryContainer, cs.onSecondaryContainer),
(cs.tertiaryContainer, cs.onTertiaryContainer),
(cs.errorContainer, cs.onErrorContainer),
(cs.primary, cs.onPrimary),
(cs.secondary, cs.onSecondary),
];
return colors[hash % colors.length];
return pairs[hash % pairs.length];
}
@override
Widget build(BuildContext context) {
final initials = _getInitials();
Widget avatar;
// If avatar URL is provided, attempt to load the image
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar(
avatar = CircleAvatar(
radius: radius,
backgroundImage: NetworkImage(avatarUrl!),
onBackgroundImageError: (_, _) {
@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
},
child: null, // Image will display if loaded successfully
);
}
} else {
final (bg, fg) = _getTonalColors(
initials,
Theme.of(context).colorScheme,
);
// Fallback to initials
return CircleAvatar(
avatar = CircleAvatar(
radius: radius,
backgroundColor: _getInitialsColor(initials),
backgroundColor: bg,
child: Text(
initials,
style: TextStyle(
color: Colors.white,
color: fg,
fontSize: radius * 0.8,
fontWeight: FontWeight.w600,
),
),
);
}
if (heroTag != null) {
return Hero(tag: heroTag!, child: avatar);
}
return avatar;
}
}

View File

@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
@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.tertiaryContainer
? scheme.primaryContainer
: scheme.tertiaryContainer;
final foreground = scheme.onTertiaryContainer;
final foreground = isEmphasized
? scheme.onPrimaryContainer
: scheme.onTertiaryContainer;
return AnimatedContainer(
duration: const Duration(milliseconds: 400),

View File

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import '../theme/app_typography.dart';
import '../theme/app_surfaces.dart';
import '../theme/m3_motion.dart';
import 'mono_text.dart';
/// A column configuration for the [TasQAdaptiveList] desktop table view.
@ -205,11 +206,18 @@ class TasQAdaptiveList<T> extends StatelessWidget {
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return _MobileTile(
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
),
);
},
);
@ -225,11 +233,17 @@ class TasQAdaptiveList<T> extends StatelessWidget {
}
final item = items[index];
final actions = rowActions?.call(item) ?? const <Widget>[];
return _MobileTile(
final staggerDelay = Duration(
milliseconds: math.min(index, 8) * 50,
);
return M3FadeSlideIn(
delay: staggerDelay,
child: _MobileTile(
item: item,
actions: actions,
mobileTileBuilder: mobileTileBuilder,
onRowTap: onRowTap,
),
);
},
shrinkWrap: true,
@ -294,30 +308,25 @@ class TasQAdaptiveList<T> extends StatelessWidget {
Widget _loadingTile(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
height: 72,
child: Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
M3ShimmerBox(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, color: Colors.white),
M3ShimmerBox(height: 12),
const SizedBox(height: 8),
Container(height: 10, width: 150, color: Colors.white),
M3ShimmerBox(width: 150, height: 10),
],
),
),
@ -325,7 +334,6 @@ class TasQAdaptiveList<T> extends StatelessWidget {
),
),
),
),
);
}

View File

@ -151,11 +151,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
),
],
if (_failed)
const Padding(
padding: EdgeInsets.only(top: 8.0),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'An error occurred while downloading. Please try again.',
style: TextStyle(color: Colors.red),
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],