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). /// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) { final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider);
final profile = profileAsync.valueOrNull; // Use .select() so the stream is only recreated when the user id or role
if (profile == null) return Stream.value(const <AttendanceLog>[]); // 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 = final hasFullAccess =
profile.role == 'admin' || profileRole == 'admin' ||
profile.role == 'programmer' || profileRole == 'programmer' ||
profile.role == 'dispatcher' || profileRole == 'dispatcher' ||
profile.role == 'it_staff'; profileRole == 'it_staff';
final wrapper = StreamRecoveryWrapper<AttendanceLog>( final wrapper = StreamRecoveryWrapper<AttendanceLog>(
stream: hasFullAccess stream: hasFullAccess
@ -47,14 +54,14 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
: client : client
.from('attendance_logs') .from('attendance_logs')
.stream(primaryKey: ['id']) .stream(primaryKey: ['id'])
.eq('user_id', profile.id) .eq('user_id', profileId)
.order('check_in_at', ascending: false), .order('check_in_at', ascending: false),
onPollData: () async { onPollData: () async {
final query = client.from('attendance_logs').select(); final query = client.from('attendance_logs').select();
final data = hasFullAccess final data = hasFullAccess
? await query.order('check_in_at', ascending: false) ? await query.order('check_in_at', ascending: false)
: await query : await query
.eq('user_id', profile.id) .eq('user_id', profileId)
.order('check_in_at', ascending: false); .order('check_in_at', ascending: false);
return data.map(AttendanceLog.fromMap).toList(); 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 dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); // Only recreate stream when user id changes (not on other profile edits).
final profile = profileAsync.valueOrNull; final profileId = ref.watch(
if (profile == null) { currentProfileProvider.select((p) => p.valueOrNull?.id),
);
if (profileId == null) {
return Stream.value(const <DutySchedule>[]); return Stream.value(const <DutySchedule>[]);
} }
@ -95,16 +97,21 @@ final dutySchedulesForUserProvider =
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) { final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
final client = ref.watch(supabaseClientProvider); final client = ref.watch(supabaseClientProvider);
final profileAsync = ref.watch(currentProfileProvider); // Only recreate stream when user id or role changes.
final profile = profileAsync.valueOrNull; final profileId = ref.watch(
if (profile == null) { currentProfileProvider.select((p) => p.valueOrNull?.id),
);
final profileRole = ref.watch(
currentProfileProvider.select((p) => p.valueOrNull?.role),
);
if (profileId == null) {
return Stream.value(const <SwapRequest>[]); return Stream.value(const <SwapRequest>[]);
} }
final isAdmin = final isAdmin =
profile.role == 'admin' || profileRole == 'admin' ||
profile.role == 'programmer' || profileRole == 'programmer' ||
profile.role == 'dispatcher'; profileRole == 'dispatcher';
final wrapper = StreamRecoveryWrapper<SwapRequest>( final wrapper = StreamRecoveryWrapper<SwapRequest>(
stream: isAdmin 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 // either party. admins still see "admin_review" rows so they can act on
// escalated cases. // escalated cases.
return result.data.where((row) { return result.data.where((row) {
if (!(row.requesterId == profile.id || row.recipientId == profile.id)) { if (!(row.requesterId == profileId || row.recipientId == profileId)) {
return false; return false;
} }
// only keep pending and admin_review statuses // 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 '../../services/ai_service.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_animated_text_field.dart';
/// A simple admin-only web page allowing the upload of a new APK and the /// A simple admin-only web page allowing the upload of a new APK and the
@ -478,13 +479,20 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
} }
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('APK Update Uploader')), body: Column(
body: Center( crossAxisAlignment: CrossAxisAlignment.stretch,
child: ConstrainedBox( children: [
constraints: const BoxConstraints(maxWidth: 800), const AppPageHeader(
child: Padding( title: 'App Update',
padding: const EdgeInsets.all(16.0), subtitle: 'Upload and manage APK releases',
child: Card( ),
Expanded(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Card(
elevation: 2, elevation: 2,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
@ -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/profile_provider.dart';
import '../../providers/tickets_provider.dart'; import '../../providers/tickets_provider.dart';
import '../../providers/services_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/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
@ -39,114 +41,118 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
maxWidth: double.infinity, maxWidth: double.infinity,
child: !isAdmin child: !isAdmin
? const Center(child: Text('Admin access required.')) ? const Center(child: Text('Admin access required.'))
: officesAsync.when( : Column(
data: (offices) { crossAxisAlignment: CrossAxisAlignment.stretch,
if (offices.isEmpty) { children: [
return const Center(child: Text('No offices found.')); const AppPageHeader(
} title: 'Office Management',
subtitle: 'Create and manage office locations',
),
Expanded(
child: officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
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 =
final filteredOffices = query.isEmpty _searchController.text.trim().toLowerCase();
? offices final filteredOffices = query.isEmpty
: offices ? offices
.where( : offices
(office) => .where(
office.name.toLowerCase().contains(query) || (office) =>
office.id.toLowerCase().contains(query), office.name
) .toLowerCase()
.toList(); .contains(query) ||
office.id
.toLowerCase()
.contains(query),
)
.toList();
final listBody = TasQAdaptiveList<Office>( return TasQAdaptiveList<Office>(
items: filteredOffices, items: filteredOffices,
filterHeader: SizedBox( filterHeader: SizedBox(
width: 320, width: 320,
child: TextField( child: TextField(
controller: _searchController, controller: _searchController,
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Search name', labelText: 'Search name',
prefixIcon: Icon(Icons.search), prefixIcon: Icon(Icons.search),
),
),
),
columns: [
TasQColumn<Office>(
header: 'Office ID',
technical: true,
cellBuilder: (context, office) => Text(office.id),
),
TasQColumn<Office>(
header: 'Office Name',
cellBuilder: (context, office) => Text(office.name),
),
],
rowActions: (office) => [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit),
onPressed: () =>
_showOfficeDialog(context, ref, office: office),
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete),
onPressed: () => _confirmDelete(context, ref, office),
),
],
mobileTileBuilder: (context, office, actions) {
return Card(
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading: const Icon(Icons.apartment_outlined),
title: Text(office.name),
subtitle: MonoText('ID ${office.id}'),
trailing: Wrap(spacing: 8, children: actions),
),
);
},
onRequestRefresh: () {
// For server-side pagination, update the query provider
ref.read(officesQueryProvider.notifier).state =
const OfficeQuery(offset: 0, limit: 50);
},
onPageChanged: (firstRow) {
ref
.read(officesQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
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),
), ),
), ),
),
columns: [
TasQColumn<Office>(
header: 'Office ID',
technical: true,
cellBuilder: (context, office) =>
Text(office.id),
),
TasQColumn<Office>(
header: 'Office Name',
cellBuilder: (context, office) =>
Text(office.name),
),
], ],
), rowActions: (office) => [
IconButton(
tooltip: 'Edit',
icon: const Icon(Icons.edit),
onPressed: () => _showOfficeDialog(
context,
ref,
office: office,
),
),
IconButton(
tooltip: 'Delete',
icon: const Icon(Icons.delete),
onPressed: () =>
_confirmDelete(context, ref, office),
),
],
mobileTileBuilder: (context, office, actions) {
return Card(
child: ListTile(
dense: true,
visualDensity: VisualDensity.compact,
leading:
const Icon(Icons.apartment_outlined),
title: Text(office.name),
subtitle: MonoText('ID ${office.id}'),
trailing: Wrap(spacing: 8, children: actions),
),
);
},
onRequestRefresh: () {
ref.read(officesQueryProvider.notifier).state =
const OfficeQuery(offset: 0, limit: 50);
},
onPageChanged: (firstRow) {
ref
.read(officesQueryProvider.notifier)
.update((q) => q.copyWith(offset: firstRow));
},
isLoading: false,
);
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => AppErrorView(
error: error,
onRetry: () => ref.invalidate(officesProvider),
), ),
Expanded(child: listBody), ),
], ),
); ],
},
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load offices: $error')),
), ),
), ),
if (isAdmin) if (isAdmin)

View File

@ -14,6 +14,8 @@ import '../../theme/app_surfaces.dart';
import '../../providers/user_offices_provider.dart'; import '../../providers/user_offices_provider.dart';
import '../../utils/app_time.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/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
@ -105,7 +107,14 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
assignmentsAsync.error ?? assignmentsAsync.error ??
messagesAsync.error ?? messagesAsync.error ??
'Unknown 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 ?? []; final profiles = profilesAsync.valueOrNull ?? [];
@ -124,7 +133,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
} }
if (profiles.isEmpty) { 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(); final query = _searchController.text.trim().toLowerCase();
@ -269,26 +282,16 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
isLoading: false, isLoading: false,
); );
return Padding( return Column(
padding: const EdgeInsets.symmetric(vertical: 16), mainAxisSize: MainAxisSize.max,
child: Column( crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.max, children: [
crossAxisAlignment: CrossAxisAlignment.stretch, const AppPageHeader(
children: [ title: 'User Management',
Align( subtitle: 'Manage user roles and office assignments',
alignment: Alignment.center, ),
child: Text( Expanded(child: listBody),
'User Management', ],
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
),
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_animated_text_field.dart';
import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_button.dart';
import '../../widgets/multi_select_picker.dart'; import '../../widgets/multi_select_picker.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
class AttendanceScreen extends ConsumerStatefulWidget { class AttendanceScreen extends ConsumerStatefulWidget {
@ -86,20 +87,9 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
: null, : null,
body: Column( body: Column(
children: [ children: [
Padding( const AppPageHeader(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), title: 'Attendance',
child: Row( subtitle: 'Check in, logbook, pass slip and leave',
children: [
Expanded(
child: Text(
'Attendance',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
), ),
TabBar( TabBar(
controller: _tabController, controller: _tabController,

View File

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

View File

@ -38,6 +38,7 @@ import '../../providers/realtime_controller.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/app_page_header.dart';
import '../../utils/app_time.dart'; import '../../utils/app_time.dart';
class DashboardMetrics { class DashboardMetrics {
@ -732,20 +733,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
final content = Column( final content = Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( const AppPageHeader(
padding: const EdgeInsets.only(top: 16, bottom: 8), title: 'Dashboard',
child: Align( subtitle: 'Live metrics and team activity',
alignment: Alignment.center,
child: Text(
'Dashboard',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
), ),
const _DashboardStatusBanner(), const _DashboardStatusBanner(),
...sections, ...sections,
@ -864,12 +856,34 @@ class _DashboardStatusBanner extends ConsumerWidget {
if (bannerState.startsWith('error:')) { if (bannerState.startsWith('error:')) {
final errorText = bannerState.substring(6); final errorText = bannerState.substring(6);
final cs = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: Text( child: Material(
'Dashboard data error: $errorText', color: cs.errorContainer,
style: Theme.of(context).textTheme.bodySmall?.copyWith( borderRadius: BorderRadius.circular(12),
color: Theme.of(context).colorScheme.error, 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(
errorText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
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/m3_card.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/reconnect_overlay.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/responsive_body.dart';
import '../../widgets/status_pill.dart'; import '../../widgets/status_pill.dart';
@ -103,17 +105,20 @@ class _ItServiceRequestsListScreenState
child: Builder( child: Builder(
builder: (context) { builder: (context) {
if (requestsAsync.hasError && !requestsAsync.hasValue) { if (requestsAsync.hasError && !requestsAsync.hasValue) {
return Center( return AppErrorView(
child: Text( error: requestsAsync.error!,
'Failed to load requests: ${requestsAsync.error}', onRetry: () =>
), ref.invalidate(itServiceRequestsProvider),
); );
} }
final allRequests = final allRequests =
requestsAsync.valueOrNull ?? <ItServiceRequest>[]; requestsAsync.valueOrNull ?? <ItServiceRequest>[];
if (allRequests.isEmpty && !showSkeleton) { if (allRequests.isEmpty && !showSkeleton) {
return const Center( return const AppEmptyView(
child: Text('No IT service requests yet.'), 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>[]; final offices = officesAsync.valueOrNull ?? <Office>[];
@ -146,6 +151,10 @@ class _ItServiceRequestsListScreenState
return Column( return Column(
children: [ children: [
const AppPageHeader(
title: 'IT Service Requests',
subtitle: 'Manage and track IT support tickets',
),
// Status summary cards // Status summary cards
_StatusSummaryRow( _StatusSummaryRow(
requests: allRequests, requests: allRequests,

View File

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

View File

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

View File

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

View File

@ -28,6 +28,8 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../utils/subject_suggestions.dart'; import '../../utils/subject_suggestions.dart';
import '../../widgets/gemini_button.dart'; import '../../widgets/gemini_button.dart';
import '../../widgets/gemini_animated_text_field.dart'; import '../../widgets/gemini_animated_text_field.dart';
@ -156,17 +158,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
builder: (context) { builder: (context) {
// Show error only when there is genuinely no data. // Show error only when there is genuinely no data.
if (tasksAsync.hasError && !tasksAsync.hasValue) { if (tasksAsync.hasError && !tasksAsync.hasValue) {
return Center( return AppErrorView(
child: Text('Failed to load tasks: ${tasksAsync.error}'), error: tasksAsync.error!,
title: 'Could not load tasks',
onRetry: () => ref.invalidate(tasksProvider),
); );
} }
final tasks = tasksAsync.valueOrNull ?? <Task>[]; 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 offices = officesAsync.valueOrNull ?? <Office>[];
final officesSorted = List<Office>.from(offices) final officesSorted = List<Office>.from(offices)
..sort( ..sort(
@ -479,12 +478,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
], ],
if (hasMention) if (hasMention)
const Padding( Padding(
padding: EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Icon( child: Icon(
Icons.circle, Icons.circle,
size: 10, size: 10,
color: Colors.red, color: Theme.of(context).colorScheme.error,
), ),
), ),
], ],
@ -513,17 +512,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( const AppPageHeader(
padding: const EdgeInsets.only(top: 16, bottom: 8), title: 'Tasks',
child: Align( subtitle: 'Work items assigned to your team',
alignment: Alignment.center,
child: Text(
'Tasks',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
), ),
Expanded( Expanded(
child: Column( child: Column(
@ -539,8 +530,28 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
children: [ children: [
makeList(myTasks), myTasks.isEmpty && !effectiveShowSkeleton
makeList(filteredTasks), ? 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 'package:tasq/widgets/multi_select_picker.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/tasq_adaptive_list.dart'; import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so // Note: `officesProvider` is provided globally in `tickets_provider.dart` so
@ -233,30 +235,19 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( const AppPageHeader(
padding: const EdgeInsets.only(top: 16, bottom: 8), title: 'IT Staff Teams',
child: Stack( subtitle: 'Manage support teams and assignments',
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,
),
),
),
],
),
), ),
Expanded(child: listBody), Expanded(child: listBody),
], ],
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), 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( floatingActionButton: M3Fab(
onPressed: () => _showTeamDialog(context), onPressed: () => _showTeamDialog(context),

View File

@ -21,6 +21,8 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.dart';
import '../../widgets/app_state_view.dart';
class TicketsListScreen extends ConsumerStatefulWidget { class TicketsListScreen extends ConsumerStatefulWidget {
const TicketsListScreen({super.key}); const TicketsListScreen({super.key});
@ -90,6 +92,14 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
builder: (context) { builder: (context) {
// Build the list UI immediately so `Skeletonizer` can // Build the list UI immediately so `Skeletonizer` can
// render placeholders while providers are still loading. // 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 tickets = ticketsAsync.valueOrNull ?? <Ticket>[];
final officeById = <String, Office>{ final officeById = <String, Office>{
for (final office in officesAsync.valueOrNull ?? <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>( final listBody = TasQAdaptiveList<Ticket>(
items: filteredTickets, items: filteredTickets,
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
@ -303,12 +338,12 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
), ),
], ],
if (hasMention) if (hasMention)
const Padding( Padding(
padding: EdgeInsets.only(left: 8), padding: const EdgeInsets.only(left: 8),
child: Icon( child: Icon(
Icons.circle, Icons.circle,
size: 10, size: 10,
color: Colors.red, color: Theme.of(context).colorScheme.error,
), ),
), ),
], ],
@ -323,17 +358,9 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Padding( const AppPageHeader(
padding: const EdgeInsets.only(top: 16, bottom: 8), title: 'Tickets',
child: Align( subtitle: 'Support requests and service tickets',
alignment: Alignment.center,
child: Text(
'Tickets',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w700),
),
),
), ),
Expanded(child: listBody), Expanded(child: listBody),
], ],

View File

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

View File

@ -14,6 +14,8 @@ import '../../providers/rotation_config_provider.dart';
import '../../providers/workforce_provider.dart'; import '../../providers/workforce_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../providers/ramadan_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 '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
@ -30,59 +32,71 @@ class WorkforceScreen extends ConsumerWidget {
role == 'admin' || role == 'programmer' || role == 'dispatcher'; role == 'admin' || role == 'programmer' || role == 'dispatcher';
return ResponsiveBody( return ResponsiveBody(
child: LayoutBuilder( child: Column(
builder: (context, constraints) { children: [
final isWide = constraints.maxWidth >= 980; const AppPageHeader(
final schedulePanel = _SchedulePanel(isAdmin: isAdmin); title: 'Workforce',
final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin); subtitle: 'Duty schedules and shift management',
final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin); ),
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,
);
if (isWide) { if (isWide) {
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(flex: 3, child: schedulePanel), Expanded(flex: 3, child: schedulePanel),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
flex: 2, flex: 2,
child: Column(
children: [
if (isAdmin) generatorPanel,
if (isAdmin) const SizedBox(height: 16),
Expanded(child: swapsPanel),
],
),
),
],
);
}
return DefaultTabController(
length: isAdmin ? 3 : 2,
child: Column( child: Column(
children: [ children: [
if (isAdmin) generatorPanel, const SizedBox(height: 8),
if (isAdmin) const SizedBox(height: 16), TabBar(
Expanded(child: swapsPanel), tabs: [
const Tab(text: 'Schedule'),
const Tab(text: 'Swaps'),
if (isAdmin) const Tab(text: 'Generator'),
],
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
schedulePanel,
swapsPanel,
if (isAdmin) generatorPanel,
],
),
),
], ],
), ),
), );
], },
);
}
return DefaultTabController(
length: isAdmin ? 3 : 2,
child: Column(
children: [
const SizedBox(height: 8),
TabBar(
tabs: [
const Tab(text: 'Schedule'),
const Tab(text: 'Swaps'),
if (isAdmin) const Tab(text: 'Generator'),
],
),
const SizedBox(height: 8),
Expanded(
child: TabBarView(
children: [
schedulePanel,
swapsPanel,
if (isAdmin) generatorPanel,
],
),
),
],
), ),
); ),
}, ],
), ),
); );
} }
@ -140,7 +154,12 @@ class _SchedulePanel extends ConsumerWidget {
.toList(); .toList();
if (schedules.isEmpty) { 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 = { final Map<String, Profile> profileById = {
@ -192,6 +211,7 @@ class _SchedulePanel extends ConsumerWidget {
), ),
isMine: schedule.userId == currentUserId, isMine: schedule.userId == currentUserId,
isAdmin: isAdmin, isAdmin: isAdmin,
role: profileById[schedule.userId]?.role,
), ),
), ),
], ],
@ -201,8 +221,10 @@ class _SchedulePanel extends ConsumerWidget {
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => error: (error, _) => AppErrorView(
Center(child: Text('Failed to load schedules: $error')), error: error,
onRetry: () => ref.invalidate(dutySchedulesProvider),
),
), ),
), ),
], ],
@ -280,6 +302,7 @@ class _ScheduleTile extends ConsumerWidget {
required this.relieverLabels, required this.relieverLabels,
required this.isMine, required this.isMine,
required this.isAdmin, required this.isAdmin,
this.role,
}); });
final DutySchedule schedule; final DutySchedule schedule;
@ -287,29 +310,27 @@ class _ScheduleTile extends ConsumerWidget {
final List<String> relieverLabels; final List<String> relieverLabels;
final bool isMine; final bool isMine;
final bool isAdmin; final bool isAdmin;
final String? role;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentUserId = ref.watch(currentUserIdProvider); final currentUserId = ref.watch(currentUserIdProvider);
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? []; // 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 now = AppTime.now();
final isPast = schedule.startTime.isBefore(now); final isPast = schedule.startTime.isBefore(now);
final hasRequestedSwap = swaps.any(
(swap) =>
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
);
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast; 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; final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
ShiftTypeConfig? shiftTypeConfig; ShiftTypeConfig? shiftTypeConfig;
@ -889,15 +910,16 @@ class _ScheduleTile extends ConsumerWidget {
} }
Color _statusColor(BuildContext context, String status) { Color _statusColor(BuildContext context, String status) {
final cs = Theme.of(context).colorScheme;
switch (status) { switch (status) {
case 'arrival': case 'arrival':
return Colors.green; return cs.tertiary;
case 'late': case 'late':
return Colors.orange; return cs.secondary;
case 'absent': case 'absent':
return Colors.red; return cs.error;
default: 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:flutter/material.dart';
import 'package:go_router/go_router.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. /// M3 Expressive motion constants and helpers.
/// ///
/// Transitions use spring-physics inspired curves with an emphasized easing /// Transitions use spring-physics inspired curves with an emphasized easing
@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State<M3FadeSlideIn>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Skip animation entirely when the OS requests reduced motion.
if (m3ReducedMotion(context)) return widget.child;
return FadeTransition( return FadeTransition(
opacity: _opacity, opacity: _opacity,
child: SlideTransition(position: _slide, child: widget.child), child: SlideTransition(position: _slide, child: widget.child),
@ -514,3 +525,391 @@ Future<T?> m3ShowBottomSheet<T>({
builder: builder, 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) { void showSuccessSnackBar(BuildContext context, String message) {
showSuccessSnackBarGlobal(message); showAwesomeSnackBar(
context,
title: 'Success',
message: message,
snackType: SnackType.success,
);
} }
void showErrorSnackBar(BuildContext context, String message) { void showErrorSnackBar(BuildContext context, String message) {
showErrorSnackBarGlobal(message); showAwesomeSnackBar(
context,
title: 'Error',
message: message,
snackType: SnackType.error,
);
} }
void showInfoSnackBar(BuildContext context, String message) { void showInfoSnackBar(BuildContext context, String message) {
showInfoSnackBarGlobal(message); showAwesomeSnackBar(
context,
title: 'Info',
message: message,
snackType: SnackType.info,
);
} }
void showWarningSnackBar(BuildContext context, String message) { 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. /// 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, fullName: displayName,
avatarUrl: avatarUrl, avatarUrl: avatarUrl,
radius: 16, radius: 16,
heroTag: 'profile-avatar',
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(displayName), Text(displayName),
@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
fullName: displayName, fullName: displayName,
avatarUrl: avatarUrl, avatarUrl: avatarUrl,
radius: 16, radius: 16,
heroTag: 'profile-avatar',
), ),
), ),
IconButton( IconButton(
@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider); final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme;
return IconButton( return IconButton(
tooltip: 'Notifications', tooltip: 'Notifications',
onPressed: () => context.go('/notifications'), onPressed: () => context.go('/notifications'),
icon: Stack( icon: Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
const Icon(Icons.notifications), const Icon(Icons.notifications_outlined),
if (unreadCount > 0) AnimatedSwitcher(
const Positioned( duration: const Duration(milliseconds: 200),
right: -2, switchInCurve: Curves.easeOutBack,
top: -2, switchOutCurve: Curves.easeInCubic,
child: Icon(Icons.circle, size: 10, color: Colors.red), 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, icon: Icons.apartment_outlined,
selectedIcon: Icons.apartment, selectedIcon: Icons.apartment,
), ),
NavItem(
label: 'Geofence test',
route: '/settings/geofence-test',
icon: Icons.map_outlined,
selectedIcon: Icons.map,
),
NavItem( NavItem(
label: 'IT Staff Teams', label: 'IT Staff Teams',
route: '/settings/teams', route: '/settings/teams',
icon: Icons.groups_2_outlined, icon: Icons.groups_2_outlined,
selectedIcon: Icons.groups_2, selectedIcon: Icons.groups_2,
), ),
NavItem(
label: 'Permissions',
route: '/settings/permissions',
icon: Icons.lock_open,
selectedIcon: Icons.lock,
),
if (kIsWeb) ...[ if (kIsWeb) ...[
NavItem( NavItem(
label: 'App Update', 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 [ return [
NavSection(label: 'Operations', items: mainItems), NavSection(label: 'Operations', items: mainItems),
NavSection( NavSection(
label: 'Settings', label: 'Settings',
items: [ items: [
NavItem( NavItem(
label: 'Permissions', label: 'Logout',
route: '/settings/permissions', route: '',
icon: Icons.lock_open, icon: Icons.logout,
selectedIcon: Icons.lock, 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: /// Native Flutter profile avatar that displays either:
/// 1. User's avatar image URL (if provided) /// 1. User's avatar image URL (if provided)
/// 2. Initials derived from full name (fallback) /// 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 { class ProfileAvatar extends StatelessWidget {
const ProfileAvatar({ const ProfileAvatar({
super.key, super.key,
required this.fullName, required this.fullName,
this.avatarUrl, this.avatarUrl,
this.radius = 18, this.radius = 18,
this.heroTag,
}); });
final String fullName; final String fullName;
final String? avatarUrl; final String? avatarUrl;
final double radius; final double radius;
/// When non-null, wraps the avatar in a [Hero] with this tag.
final Object? heroTag;
String _getInitials() { String _getInitials() {
final trimmed = fullName.trim(); final trimmed = fullName.trim();
if (trimmed.isEmpty) return 'U'; if (trimmed.isEmpty) return 'U';
@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
return '${parts.first[0]}${parts.last[0]}'.toUpperCase(); return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
} }
Color _getInitialsColor(String initials) { /// Returns a (background, foreground) pair from the M3 tonal palette.
// Generate a deterministic color based on initials ///
/// 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 = final hash =
initials.codeUnitAt(0) + initials.codeUnitAt(0) +
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0); (initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
final colors = [
Colors.red, // Six M3-compliant container pairs (background / on-color text).
Colors.pink, final pairs = [
Colors.purple, (cs.primaryContainer, cs.onPrimaryContainer),
Colors.deepPurple, (cs.secondaryContainer, cs.onSecondaryContainer),
Colors.indigo, (cs.tertiaryContainer, cs.onTertiaryContainer),
Colors.blue, (cs.errorContainer, cs.onErrorContainer),
Colors.lightBlue, (cs.primary, cs.onPrimary),
Colors.cyan, (cs.secondary, cs.onSecondary),
Colors.teal,
Colors.green,
Colors.lightGreen,
Colors.orange,
Colors.deepOrange,
Colors.brown,
]; ];
return colors[hash % colors.length];
return pairs[hash % pairs.length];
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final initials = _getInitials(); final initials = _getInitials();
Widget avatar;
// If avatar URL is provided, attempt to load the image // If avatar URL is provided, attempt to load the image
if (avatarUrl != null && avatarUrl!.isNotEmpty) { if (avatarUrl != null && avatarUrl!.isNotEmpty) {
return CircleAvatar( avatar = CircleAvatar(
radius: radius, radius: radius,
backgroundImage: NetworkImage(avatarUrl!), backgroundImage: NetworkImage(avatarUrl!),
onBackgroundImageError: (_, _) { onBackgroundImageError: (_, _) {
@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
}, },
child: null, // Image will display if loaded successfully child: null, // Image will display if loaded successfully
); );
} else {
final (bg, fg) = _getTonalColors(
initials,
Theme.of(context).colorScheme,
);
// Fallback to initials
avatar = CircleAvatar(
radius: radius,
backgroundColor: bg,
child: Text(
initials,
style: TextStyle(
color: fg,
fontSize: radius * 0.8,
fontWeight: FontWeight.w600,
),
),
);
} }
// Fallback to initials if (heroTag != null) {
return CircleAvatar( return Hero(tag: heroTag!, child: avatar);
radius: radius, }
backgroundColor: _getInitialsColor(initials), return avatar;
child: Text(
initials,
style: TextStyle(
color: Colors.white,
fontSize: radius * 0.8,
fontWeight: FontWeight.w600,
),
),
);
} }
} }

View File

@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
// isEmphasized uses primaryContainer (higher tonal weight) to visually
// distinguish high-priority statuses from routine ones.
final background = isEmphasized final background = isEmphasized
? scheme.tertiaryContainer ? scheme.primaryContainer
: scheme.tertiaryContainer; : scheme.tertiaryContainer;
final foreground = scheme.onTertiaryContainer; final foreground = isEmphasized
? scheme.onPrimaryContainer
: scheme.onTertiaryContainer;
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),

View File

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

View File

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