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
+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;
}
}
}