Enhanced material design 3 implementation
This commit is contained in:
parent
27ebb89052
commit
74197c525d
|
|
@ -28,15 +28,22 @@ final attendanceUserFilterProvider = StateProvider<List<String>>((ref) => []);
|
|||
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) return Stream.value(const <AttendanceLog>[]);
|
||||
|
||||
// Use .select() so the stream is only recreated when the user id or role
|
||||
// actually changes (not on avatar/name edits, etc.).
|
||||
final profileId = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||
);
|
||||
final profileRole = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.role),
|
||||
);
|
||||
if (profileId == null) return Stream.value(const <AttendanceLog>[]);
|
||||
|
||||
final hasFullAccess =
|
||||
profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher' ||
|
||||
profile.role == 'it_staff';
|
||||
profileRole == 'admin' ||
|
||||
profileRole == 'programmer' ||
|
||||
profileRole == 'dispatcher' ||
|
||||
profileRole == 'it_staff';
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
||||
stream: hasFullAccess
|
||||
|
|
@ -47,14 +54,14 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
|||
: client
|
||||
.from('attendance_logs')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('user_id', profile.id)
|
||||
.eq('user_id', profileId)
|
||||
.order('check_in_at', ascending: false),
|
||||
onPollData: () async {
|
||||
final query = client.from('attendance_logs').select();
|
||||
final data = hasFullAccess
|
||||
? await query.order('check_in_at', ascending: false)
|
||||
: await query
|
||||
.eq('user_id', profile.id)
|
||||
.eq('user_id', profileId)
|
||||
.order('check_in_at', ascending: false);
|
||||
return data.map(AttendanceLog.fromMap).toList();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
|
|||
|
||||
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) {
|
||||
// Only recreate stream when user id changes (not on other profile edits).
|
||||
final profileId = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||
);
|
||||
if (profileId == null) {
|
||||
return Stream.value(const <DutySchedule>[]);
|
||||
}
|
||||
|
||||
|
|
@ -95,16 +97,21 @@ final dutySchedulesForUserProvider =
|
|||
|
||||
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
||||
final client = ref.watch(supabaseClientProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final profile = profileAsync.valueOrNull;
|
||||
if (profile == null) {
|
||||
// Only recreate stream when user id or role changes.
|
||||
final profileId = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||
);
|
||||
final profileRole = ref.watch(
|
||||
currentProfileProvider.select((p) => p.valueOrNull?.role),
|
||||
);
|
||||
if (profileId == null) {
|
||||
return Stream.value(const <SwapRequest>[]);
|
||||
}
|
||||
|
||||
final isAdmin =
|
||||
profile.role == 'admin' ||
|
||||
profile.role == 'programmer' ||
|
||||
profile.role == 'dispatcher';
|
||||
profileRole == 'admin' ||
|
||||
profileRole == 'programmer' ||
|
||||
profileRole == 'dispatcher';
|
||||
|
||||
final wrapper = StreamRecoveryWrapper<SwapRequest>(
|
||||
stream: isAdmin
|
||||
|
|
@ -135,7 +142,7 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
|||
// either party. admins still see "admin_review" rows so they can act on
|
||||
// escalated cases.
|
||||
return result.data.where((row) {
|
||||
if (!(row.requesterId == profile.id || row.recipientId == profile.id)) {
|
||||
if (!(row.requesterId == profileId || row.recipientId == profileId)) {
|
||||
return false;
|
||||
}
|
||||
// only keep pending and admin_review statuses
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@ import 'dart:math' as math;
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// ── Reduced-motion helper ─────────────────────────────────────────────────────
|
||||
|
||||
/// Returns true when the OS/user has requested reduced motion.
|
||||
///
|
||||
/// Always query this in the [build] method — never in [initState] — because
|
||||
/// the value is read from [MediaQuery] which requires a valid [BuildContext].
|
||||
bool m3ReducedMotion(BuildContext context) =>
|
||||
MediaQuery.of(context).disableAnimations;
|
||||
|
||||
/// M3 Expressive motion constants and helpers.
|
||||
///
|
||||
/// Transitions use spring-physics inspired curves with an emphasized easing
|
||||
|
|
@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State<M3FadeSlideIn>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Skip animation entirely when the OS requests reduced motion.
|
||||
if (m3ReducedMotion(context)) return widget.child;
|
||||
return FadeTransition(
|
||||
opacity: _opacity,
|
||||
child: SlideTransition(position: _slide, child: widget.child),
|
||||
|
|
@ -514,3 +525,391 @@ Future<T?> m3ShowBottomSheet<T>({
|
|||
builder: builder,
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3ShimmerBox — animated loading skeleton shimmer
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// An animated shimmer placeholder used during loading states.
|
||||
///
|
||||
/// The highlight sweeps from left to right at 1.2-second intervals,
|
||||
/// matching the "loading skeleton" pattern from M3 guidelines.
|
||||
/// Automatically falls back to a static surface when the OS requests
|
||||
/// reduced motion.
|
||||
///
|
||||
/// ```dart
|
||||
/// M3ShimmerBox(width: 120, height: 14, borderRadius: BorderRadius.circular(4))
|
||||
/// ```
|
||||
class M3ShimmerBox extends StatefulWidget {
|
||||
const M3ShimmerBox({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius,
|
||||
this.child,
|
||||
});
|
||||
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
/// Defaults to `BorderRadius.circular(6)` when null.
|
||||
final BorderRadius? borderRadius;
|
||||
|
||||
/// Optional child rendered on top of the shimmer surface.
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
State<M3ShimmerBox> createState() => _M3ShimmerBoxState();
|
||||
}
|
||||
|
||||
class _M3ShimmerBoxState extends State<M3ShimmerBox>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final radius = widget.borderRadius ?? BorderRadius.circular(6);
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
final highlightColor = cs.surfaceContainerHigh;
|
||||
|
||||
if (m3ReducedMotion(context)) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(color: baseColor, borderRadius: radius),
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, child) {
|
||||
// Shimmer highlight sweeps from -0.5 → 1.5 across the widget width.
|
||||
final t = _ctrl.value;
|
||||
final dx = -0.5 + t * 2.0;
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment(dx - 0.5, 0),
|
||||
end: Alignment(dx + 0.5, 0),
|
||||
colors: [baseColor, highlightColor, baseColor],
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3PressScale — press-to-scale micro-interaction
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Wraps any widget with a subtle scale-down animation on press, giving
|
||||
/// tactile visual feedback for tappable surfaces (cards, list tiles, etc.).
|
||||
///
|
||||
/// Uses [M3Motion.micro] duration with [M3Motion.emphasizedEnter] curve
|
||||
/// so the press response feels instant but controlled.
|
||||
///
|
||||
/// ```dart
|
||||
/// M3PressScale(
|
||||
/// onTap: () => context.go('/detail'),
|
||||
/// child: Card(child: ...),
|
||||
/// )
|
||||
/// ```
|
||||
class M3PressScale extends StatefulWidget {
|
||||
const M3PressScale({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.onLongPress,
|
||||
this.scale = 0.97,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// The scale factor applied during a press. Defaults to `0.97`.
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
State<M3PressScale> createState() => _M3PressScaleState();
|
||||
}
|
||||
|
||||
class _M3PressScaleState extends State<M3PressScale>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _scaleAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: M3Motion.micro);
|
||||
_scaleAnim = Tween<double>(begin: 1.0, end: widget.scale).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: M3Motion.emphasizedEnter),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onDown(TapDownDetails _) => _ctrl.forward();
|
||||
void _onUp(TapUpDetails _) => _ctrl.reverse();
|
||||
void _onCancel() => _ctrl.reverse();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Skip animation when the OS requests reduced motion.
|
||||
if (m3ReducedMotion(context)) {
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _onDown,
|
||||
onTapUp: _onUp,
|
||||
onTapCancel: _onCancel,
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: ScaleTransition(scale: _scaleAnim, child: widget.child),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3ErrorShake — horizontal shake animation for validation errors
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Plays a brief horizontal shake animation whenever [hasError] transitions
|
||||
/// from `false` to `true` — ideal for wrapping form cards or individual
|
||||
/// fields to signal a validation failure.
|
||||
///
|
||||
/// Automatically skips the animation when the OS requests reduced motion.
|
||||
///
|
||||
/// ```dart
|
||||
/// M3ErrorShake(
|
||||
/// hasError: _isLoading == false && _errorMessage != null,
|
||||
/// child: Card(child: Form(...)),
|
||||
/// )
|
||||
/// ```
|
||||
class M3ErrorShake extends StatefulWidget {
|
||||
const M3ErrorShake({
|
||||
super.key,
|
||||
required this.child,
|
||||
required this.hasError,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
/// When this transitions from `false` → `true` the shake fires once.
|
||||
final bool hasError;
|
||||
|
||||
@override
|
||||
State<M3ErrorShake> createState() => _M3ErrorShakeState();
|
||||
}
|
||||
|
||||
class _M3ErrorShakeState extends State<M3ErrorShake>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
bool _prevError = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(M3ErrorShake oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.hasError && !_prevError) {
|
||||
_ctrl.forward(from: 0);
|
||||
}
|
||||
_prevError = widget.hasError;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (m3ReducedMotion(context)) return widget.child;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, child) {
|
||||
// Damped oscillation: amplitude fades as t → 1.
|
||||
final t = _ctrl.value;
|
||||
final dx = math.sin(t * math.pi * 5) * 8.0 * (1.0 - t);
|
||||
return Transform.translate(offset: Offset(dx, 0), child: child);
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3BounceIcon — entrance + idle pulse for empty/error states
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Displays an [icon] inside a colored circular badge with:
|
||||
/// 1. A spring-bounce entrance animation on first build.
|
||||
/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats
|
||||
/// indefinitely to draw attention without being distracting.
|
||||
///
|
||||
/// Respects [m3ReducedMotion] — both animations are skipped when reduced
|
||||
/// motion is enabled.
|
||||
class M3BounceIcon extends StatefulWidget {
|
||||
const M3BounceIcon({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.backgroundColor,
|
||||
this.size = 72.0,
|
||||
this.iconSize = 36.0,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final Color backgroundColor;
|
||||
final double size;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
State<M3BounceIcon> createState() => _M3BounceIconState();
|
||||
}
|
||||
|
||||
class _M3BounceIconState extends State<M3BounceIcon>
|
||||
with TickerProviderStateMixin {
|
||||
late final AnimationController _entranceCtrl;
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _entrance;
|
||||
late final Animation<double> _pulse;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Entrance: scale 0 → 1.0 with spring overshoot.
|
||||
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||||
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
||||
|
||||
// Idle pulse: 1.0 → 1.06 → 1.0, repeating every 2.5 s.
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
)..repeat(reverse: true);
|
||||
_pulse = Tween<double>(begin: 1.0, end: 1.06).animate(
|
||||
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_entranceCtrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entranceCtrl.dispose();
|
||||
_pulseCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final badge = Container(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.backgroundColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor),
|
||||
);
|
||||
|
||||
if (m3ReducedMotion(context)) return badge;
|
||||
|
||||
return ScaleTransition(
|
||||
scale: _entrance,
|
||||
child: AnimatedBuilder(
|
||||
animation: _pulse,
|
||||
builder: (_, child) =>
|
||||
Transform.scale(scale: _pulse.value, child: child),
|
||||
child: badge,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// M3AnimatedCounter — smooth number animation
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/// Animates an integer value from its previous value to [value] using a
|
||||
/// [TweenAnimationBuilder], producing a smooth counting effect on metric
|
||||
/// cards and dashboard KPIs.
|
||||
///
|
||||
/// ```dart
|
||||
/// M3AnimatedCounter(
|
||||
/// value: totalTasks,
|
||||
/// style: theme.textTheme.headlineMedium,
|
||||
/// )
|
||||
/// ```
|
||||
class M3AnimatedCounter extends StatelessWidget {
|
||||
const M3AnimatedCounter({
|
||||
super.key,
|
||||
required this.value,
|
||||
this.style,
|
||||
this.duration = M3Motion.standard,
|
||||
});
|
||||
|
||||
final int value;
|
||||
final TextStyle? style;
|
||||
final Duration duration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (m3ReducedMotion(context)) {
|
||||
return Text(value.toString(), style: style);
|
||||
}
|
||||
|
||||
return TweenAnimationBuilder<int>(
|
||||
tween: IntTween(begin: 0, end: value),
|
||||
duration: duration,
|
||||
curve: M3Motion.emphasizedEnter,
|
||||
builder: (_, v, _) => Text(v.toString(), style: style),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,19 +114,39 @@ void showAwesomeSnackBar(
|
|||
}
|
||||
|
||||
void showSuccessSnackBar(BuildContext context, String message) {
|
||||
showSuccessSnackBarGlobal(message);
|
||||
showAwesomeSnackBar(
|
||||
context,
|
||||
title: 'Success',
|
||||
message: message,
|
||||
snackType: SnackType.success,
|
||||
);
|
||||
}
|
||||
|
||||
void showErrorSnackBar(BuildContext context, String message) {
|
||||
showErrorSnackBarGlobal(message);
|
||||
showAwesomeSnackBar(
|
||||
context,
|
||||
title: 'Error',
|
||||
message: message,
|
||||
snackType: SnackType.error,
|
||||
);
|
||||
}
|
||||
|
||||
void showInfoSnackBar(BuildContext context, String message) {
|
||||
showInfoSnackBarGlobal(message);
|
||||
showAwesomeSnackBar(
|
||||
context,
|
||||
title: 'Info',
|
||||
message: message,
|
||||
snackType: SnackType.info,
|
||||
);
|
||||
}
|
||||
|
||||
void showWarningSnackBar(BuildContext context, String message) {
|
||||
showWarningSnackBarGlobal(message);
|
||||
showAwesomeSnackBar(
|
||||
context,
|
||||
title: 'Warning',
|
||||
message: message,
|
||||
snackType: SnackType.warning,
|
||||
);
|
||||
}
|
||||
|
||||
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.
|
||||
|
|
|
|||
101
lib/widgets/app_page_header.dart
Normal file
101
lib/widgets/app_page_header.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -84,6 +84,7 @@ class AppScaffold extends ConsumerWidget {
|
|||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(displayName),
|
||||
|
|
@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
|
|||
fullName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
radius: 16,
|
||||
heroTag: 'profile-avatar',
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
|
|
@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget {
|
|||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final unreadCount = ref.watch(unreadNotificationsCountProvider);
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return IconButton(
|
||||
tooltip: 'Notifications',
|
||||
onPressed: () => context.go('/notifications'),
|
||||
icon: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
const Icon(Icons.notifications),
|
||||
if (unreadCount > 0)
|
||||
const Positioned(
|
||||
right: -2,
|
||||
top: -2,
|
||||
child: Icon(Icons.circle, size: 10, color: Colors.red),
|
||||
const Icon(Icons.notifications_outlined),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
switchInCurve: Curves.easeOutBack,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => ScaleTransition(
|
||||
scale: animation,
|
||||
child: child,
|
||||
),
|
||||
child: unreadCount > 0
|
||||
? Positioned(
|
||||
key: const ValueKey('badge'),
|
||||
right: -3,
|
||||
top: -3,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: cs.surface,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
@ -422,24 +447,12 @@ List<NavSection> _buildSections(String role) {
|
|||
icon: Icons.apartment_outlined,
|
||||
selectedIcon: Icons.apartment,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Geofence test',
|
||||
route: '/settings/geofence-test',
|
||||
icon: Icons.map_outlined,
|
||||
selectedIcon: Icons.map,
|
||||
),
|
||||
NavItem(
|
||||
label: 'IT Staff Teams',
|
||||
route: '/settings/teams',
|
||||
icon: Icons.groups_2_outlined,
|
||||
selectedIcon: Icons.groups_2,
|
||||
),
|
||||
NavItem(
|
||||
label: 'Permissions',
|
||||
route: '/settings/permissions',
|
||||
icon: Icons.lock_open,
|
||||
selectedIcon: Icons.lock,
|
||||
),
|
||||
if (kIsWeb) ...[
|
||||
NavItem(
|
||||
label: 'App Update',
|
||||
|
|
@ -459,19 +472,16 @@ List<NavSection> _buildSections(String role) {
|
|||
];
|
||||
}
|
||||
|
||||
// non-admin users still get a simple Settings section containing only
|
||||
// permissions. this keeps the screen accessible without exposing the
|
||||
// administrative management screens.
|
||||
return [
|
||||
NavSection(label: 'Operations', items: mainItems),
|
||||
NavSection(
|
||||
label: 'Settings',
|
||||
items: [
|
||||
NavItem(
|
||||
label: 'Permissions',
|
||||
route: '/settings/permissions',
|
||||
icon: Icons.lock_open,
|
||||
selectedIcon: Icons.lock,
|
||||
label: 'Logout',
|
||||
route: '',
|
||||
icon: Icons.logout,
|
||||
isLogout: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
165
lib/widgets/app_state_view.dart
Normal file
165
lib/widgets/app_state_view.dart
Normal 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!,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,18 +3,25 @@ import 'package:flutter/material.dart';
|
|||
/// Native Flutter profile avatar that displays either:
|
||||
/// 1. User's avatar image URL (if provided)
|
||||
/// 2. Initials derived from full name (fallback)
|
||||
///
|
||||
/// Pass [heroTag] to participate in a Hero transition to/from a destination
|
||||
/// that uses the same tag (e.g., the profile screen's large avatar).
|
||||
class ProfileAvatar extends StatelessWidget {
|
||||
const ProfileAvatar({
|
||||
super.key,
|
||||
required this.fullName,
|
||||
this.avatarUrl,
|
||||
this.radius = 18,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
final String fullName;
|
||||
final String? avatarUrl;
|
||||
final double radius;
|
||||
|
||||
/// When non-null, wraps the avatar in a [Hero] with this tag.
|
||||
final Object? heroTag;
|
||||
|
||||
String _getInitials() {
|
||||
final trimmed = fullName.trim();
|
||||
if (trimmed.isEmpty) return 'U';
|
||||
|
|
@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
|
|||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||
}
|
||||
|
||||
Color _getInitialsColor(String initials) {
|
||||
// Generate a deterministic color based on initials
|
||||
/// Returns a (background, foreground) pair from the M3 tonal palette.
|
||||
///
|
||||
/// Uses a deterministic hash of the initials to cycle through the scheme's
|
||||
/// semantic container colors so every avatar is theme-aware and accessible.
|
||||
(Color, Color) _getTonalColors(String initials, ColorScheme cs) {
|
||||
final hash =
|
||||
initials.codeUnitAt(0) +
|
||||
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
|
||||
final colors = [
|
||||
Colors.red,
|
||||
Colors.pink,
|
||||
Colors.purple,
|
||||
Colors.deepPurple,
|
||||
Colors.indigo,
|
||||
Colors.blue,
|
||||
Colors.lightBlue,
|
||||
Colors.cyan,
|
||||
Colors.teal,
|
||||
Colors.green,
|
||||
Colors.lightGreen,
|
||||
Colors.orange,
|
||||
Colors.deepOrange,
|
||||
Colors.brown,
|
||||
|
||||
// Six M3-compliant container pairs (background / on-color text).
|
||||
final pairs = [
|
||||
(cs.primaryContainer, cs.onPrimaryContainer),
|
||||
(cs.secondaryContainer, cs.onSecondaryContainer),
|
||||
(cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||
(cs.errorContainer, cs.onErrorContainer),
|
||||
(cs.primary, cs.onPrimary),
|
||||
(cs.secondary, cs.onSecondary),
|
||||
];
|
||||
return colors[hash % colors.length];
|
||||
|
||||
return pairs[hash % pairs.length];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final initials = _getInitials();
|
||||
|
||||
Widget avatar;
|
||||
|
||||
// If avatar URL is provided, attempt to load the image
|
||||
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
||||
return CircleAvatar(
|
||||
avatar = CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundImage: NetworkImage(avatarUrl!),
|
||||
onBackgroundImageError: (_, _) {
|
||||
|
|
@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
|
|||
},
|
||||
child: null, // Image will display if loaded successfully
|
||||
);
|
||||
} else {
|
||||
final (bg, fg) = _getTonalColors(
|
||||
initials,
|
||||
Theme.of(context).colorScheme,
|
||||
);
|
||||
|
||||
// Fallback to initials
|
||||
avatar = CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: bg,
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontSize: radius * 0.8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to initials
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: _getInitialsColor(initials),
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: radius * 0.8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (heroTag != null) {
|
||||
return Hero(tag: heroTag!, child: avatar);
|
||||
}
|
||||
return avatar;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
// isEmphasized uses primaryContainer (higher tonal weight) to visually
|
||||
// distinguish high-priority statuses from routine ones.
|
||||
final background = isEmphasized
|
||||
? scheme.tertiaryContainer
|
||||
? scheme.primaryContainer
|
||||
: scheme.tertiaryContainer;
|
||||
final foreground = scheme.onTertiaryContainer;
|
||||
final foreground = isEmphasized
|
||||
? scheme.onPrimaryContainer
|
||||
: scheme.onTertiaryContainer;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import '../theme/app_typography.dart';
|
||||
import '../theme/app_surfaces.dart';
|
||||
import '../theme/m3_motion.dart';
|
||||
import 'mono_text.dart';
|
||||
|
||||
/// A column configuration for the [TasQAdaptiveList] desktop table view.
|
||||
|
|
@ -205,11 +206,18 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
|||
}
|
||||
final item = items[index];
|
||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||
return _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
|
||||
final staggerDelay = Duration(
|
||||
milliseconds: math.min(index, 8) * 50,
|
||||
);
|
||||
return M3FadeSlideIn(
|
||||
delay: staggerDelay,
|
||||
child: _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -225,11 +233,17 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
|||
}
|
||||
final item = items[index];
|
||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||
return _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
final staggerDelay = Duration(
|
||||
milliseconds: math.min(index, 8) * 50,
|
||||
);
|
||||
return M3FadeSlideIn(
|
||||
delay: staggerDelay,
|
||||
child: _MobileTile(
|
||||
item: item,
|
||||
actions: actions,
|
||||
mobileTileBuilder: mobileTileBuilder,
|
||||
onRowTap: onRowTap,
|
||||
),
|
||||
);
|
||||
},
|
||||
shrinkWrap: true,
|
||||
|
|
@ -294,35 +308,29 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
|||
Widget _loadingTile(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: SizedBox(
|
||||
height: 72,
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
M3ShimmerBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -151,11 +151,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
|||
),
|
||||
],
|
||||
if (_failed)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Text(
|
||||
'An error occurred while downloading. Please try again.',
|
||||
style: TextStyle(color: Colors.red),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user