Enhanced material design 3 implementation

This commit is contained in:
2026-03-20 15:15:38 +08:00
parent 27ebb89052
commit 74197c525d
26 changed files with 1345 additions and 515 deletions
+19 -8
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,13 +479,20 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
}
return Scaffold(
appBar: AppBar(title: const Text('APK Update Uploader')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 800),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
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.fromLTRB(16, 0, 16, 16),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
@@ -899,6 +907,9 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
),
),
),
);
),
],
),
);
}
}
+108 -102
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,114 +41,118 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
maxWidth: double.infinity,
child: !isAdmin
? const Center(child: Text('Admin access required.'))
: officesAsync.when(
data: (offices) {
if (offices.isEmpty) {
return const Center(child: Text('No offices found.'));
}
: 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 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 filteredOffices = query.isEmpty
? offices
: offices
.where(
(office) =>
office.name.toLowerCase().contains(query) ||
office.id.toLowerCase().contains(query),
)
.toList();
final query =
_searchController.text.trim().toLowerCase();
final filteredOffices = query.isEmpty
? offices
: offices
.where(
(office) =>
office.name
.toLowerCase()
.contains(query) ||
office.id
.toLowerCase()
.contains(query),
)
.toList();
final listBody = TasQAdaptiveList<Office>(
items: filteredOffices,
filterHeader: SizedBox(
width: 320,
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Search name',
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),
return TasQAdaptiveList<Office>(
items: filteredOffices,
filterHeader: SizedBox(
width: 320,
child: TextField(
controller: _searchController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Search name',
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: () {
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)
+25 -22
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(
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 SizedBox(height: 16),
Expanded(child: listBody),
],
),
return Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'User Management',
subtitle: 'Manage user roles and office assignments',
),
Expanded(child: listBody),
],
);
}
+4 -14
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,
+12 -2
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 ──
+31 -17
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: Text(
'Dashboard data error: $errorText',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
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(
errorText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: cs.onErrorContainer,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
);
@@ -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,
@@ -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,48 +60,44 @@ 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(
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const AppPageHeader(
title: 'Notifications',
subtitle: 'Updates and mentions across tasks and tickets',
),
if (_showBanner && !_dismissed)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MaterialBanner(
content: const Text(
'Push notifications are currently silenced. Tap here to fix.',
),
),
if (_showBanner && !_dismissed)
Padding(
padding: const EdgeInsets.only(bottom: 12),
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'),
),
],
actions: [
TextButton(
onPressed: openAppSettings,
child: const Text('Open settings'),
),
),
Expanded(
child: ListView.separated(
TextButton(
onPressed: () => setState(() => _dismissed = true),
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),
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, _) => AppErrorView(
error: error,
onRetry: () => ref.invalidate(notificationsProvider),
),
],
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) =>
Center(child: Text('Failed to load notifications: $error')),
),
),
],
),
);
}
+24 -18
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,19 +290,22 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
Center(
child: Stack(
children: [
CircleAvatar(
radius: 56,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 48,
color: colors.onSurfaceVariant,
)
: null,
Hero(
tag: 'profile-avatar',
child: CircleAvatar(
radius: 56,
backgroundColor: colors.surfaceContainerHighest,
backgroundImage: avatarUrl != null
? NetworkImage(avatarUrl)
: null,
child: avatarUrl == null
? Icon(
Icons.person,
size: 48,
color: colors.onSurfaceVariant,
)
: null,
),
),
if (_uploadingAvatar)
const Positioned.fill(
@@ -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(
+6 -12
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
+34 -23
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),
],
),
),
+9 -18
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),
+41 -14
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),
],
+14 -17
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;
+91 -69
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,59 +32,71 @@ class WorkforceScreen extends ConsumerWidget {
role == 'admin' || role == 'programmer' || role == 'dispatcher';
return ResponsiveBody(
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);
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,
);
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: schedulePanel),
const SizedBox(width: 16),
Expanded(
flex: 2,
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 3, child: schedulePanel),
const SizedBox(width: 16),
Expanded(
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(
children: [
if (isAdmin) generatorPanel,
if (isAdmin) const SizedBox(height: 16),
Expanded(child: swapsPanel),
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,
],
),
),
],
),
),
],
);
}
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();
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 ?? [];
// 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 hasRequestedSwap = swaps.any(
(swap) =>
swap.requesterScheduleId == schedule.id &&
swap.requesterId == currentUserId &&
swap.status == 'pending',
);
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;
}
}
}