Enhanced material design 3 implementation
This commit is contained in:
@@ -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> {
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user