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).
|
/// All visible attendance logs (own for standard, all for admin/dispatcher/it_staff).
|
||||||
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
|
||||||
final profile = profileAsync.valueOrNull;
|
// Use .select() so the stream is only recreated when the user id or role
|
||||||
if (profile == null) return Stream.value(const <AttendanceLog>[]);
|
// actually changes (not on avatar/name edits, etc.).
|
||||||
|
final profileId = ref.watch(
|
||||||
|
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||||
|
);
|
||||||
|
final profileRole = ref.watch(
|
||||||
|
currentProfileProvider.select((p) => p.valueOrNull?.role),
|
||||||
|
);
|
||||||
|
if (profileId == null) return Stream.value(const <AttendanceLog>[]);
|
||||||
|
|
||||||
final hasFullAccess =
|
final hasFullAccess =
|
||||||
profile.role == 'admin' ||
|
profileRole == 'admin' ||
|
||||||
profile.role == 'programmer' ||
|
profileRole == 'programmer' ||
|
||||||
profile.role == 'dispatcher' ||
|
profileRole == 'dispatcher' ||
|
||||||
profile.role == 'it_staff';
|
profileRole == 'it_staff';
|
||||||
|
|
||||||
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
final wrapper = StreamRecoveryWrapper<AttendanceLog>(
|
||||||
stream: hasFullAccess
|
stream: hasFullAccess
|
||||||
|
|
@ -47,14 +54,14 @@ final attendanceLogsProvider = StreamProvider<List<AttendanceLog>>((ref) {
|
||||||
: client
|
: client
|
||||||
.from('attendance_logs')
|
.from('attendance_logs')
|
||||||
.stream(primaryKey: ['id'])
|
.stream(primaryKey: ['id'])
|
||||||
.eq('user_id', profile.id)
|
.eq('user_id', profileId)
|
||||||
.order('check_in_at', ascending: false),
|
.order('check_in_at', ascending: false),
|
||||||
onPollData: () async {
|
onPollData: () async {
|
||||||
final query = client.from('attendance_logs').select();
|
final query = client.from('attendance_logs').select();
|
||||||
final data = hasFullAccess
|
final data = hasFullAccess
|
||||||
? await query.order('check_in_at', ascending: false)
|
? await query.order('check_in_at', ascending: false)
|
||||||
: await query
|
: await query
|
||||||
.eq('user_id', profile.id)
|
.eq('user_id', profileId)
|
||||||
.order('check_in_at', ascending: false);
|
.order('check_in_at', ascending: false);
|
||||||
return data.map(AttendanceLog.fromMap).toList();
|
return data.map(AttendanceLog.fromMap).toList();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,11 @@ final showPastSchedulesProvider = StateProvider<bool>((ref) => false);
|
||||||
|
|
||||||
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
final dutySchedulesProvider = StreamProvider<List<DutySchedule>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
// Only recreate stream when user id changes (not on other profile edits).
|
||||||
final profile = profileAsync.valueOrNull;
|
final profileId = ref.watch(
|
||||||
if (profile == null) {
|
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||||
|
);
|
||||||
|
if (profileId == null) {
|
||||||
return Stream.value(const <DutySchedule>[]);
|
return Stream.value(const <DutySchedule>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,16 +97,21 @@ final dutySchedulesForUserProvider =
|
||||||
|
|
||||||
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
// Only recreate stream when user id or role changes.
|
||||||
final profile = profileAsync.valueOrNull;
|
final profileId = ref.watch(
|
||||||
if (profile == null) {
|
currentProfileProvider.select((p) => p.valueOrNull?.id),
|
||||||
|
);
|
||||||
|
final profileRole = ref.watch(
|
||||||
|
currentProfileProvider.select((p) => p.valueOrNull?.role),
|
||||||
|
);
|
||||||
|
if (profileId == null) {
|
||||||
return Stream.value(const <SwapRequest>[]);
|
return Stream.value(const <SwapRequest>[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
final isAdmin =
|
final isAdmin =
|
||||||
profile.role == 'admin' ||
|
profileRole == 'admin' ||
|
||||||
profile.role == 'programmer' ||
|
profileRole == 'programmer' ||
|
||||||
profile.role == 'dispatcher';
|
profileRole == 'dispatcher';
|
||||||
|
|
||||||
final wrapper = StreamRecoveryWrapper<SwapRequest>(
|
final wrapper = StreamRecoveryWrapper<SwapRequest>(
|
||||||
stream: isAdmin
|
stream: isAdmin
|
||||||
|
|
@ -135,7 +142,7 @@ final swapRequestsProvider = StreamProvider<List<SwapRequest>>((ref) {
|
||||||
// either party. admins still see "admin_review" rows so they can act on
|
// either party. admins still see "admin_review" rows so they can act on
|
||||||
// escalated cases.
|
// escalated cases.
|
||||||
return result.data.where((row) {
|
return result.data.where((row) {
|
||||||
if (!(row.requesterId == profile.id || row.recipientId == profile.id)) {
|
if (!(row.requesterId == profileId || row.recipientId == profileId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// only keep pending and admin_review statuses
|
// only keep pending and admin_review statuses
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import 'package:flutter_quill/flutter_quill.dart' as quill;
|
||||||
|
|
||||||
import '../../services/ai_service.dart';
|
import '../../services/ai_service.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
|
||||||
/// A simple admin-only web page allowing the upload of a new APK and the
|
/// A simple admin-only web page allowing the upload of a new APK and the
|
||||||
|
|
@ -478,13 +479,20 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('APK Update Uploader')),
|
body: Column(
|
||||||
body: Center(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
child: ConstrainedBox(
|
children: [
|
||||||
constraints: const BoxConstraints(maxWidth: 800),
|
const AppPageHeader(
|
||||||
child: Padding(
|
title: 'App Update',
|
||||||
padding: const EdgeInsets.all(16.0),
|
subtitle: 'Upload and manage APK releases',
|
||||||
child: Card(
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 800),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
|
child: Card(
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|
@ -899,6 +907,9 @@ class _AppUpdateScreenState extends ConsumerState<AppUpdateScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import '../../models/office.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/services_provider.dart';
|
import '../../providers/services_provider.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
|
|
@ -39,114 +41,118 @@ class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
maxWidth: double.infinity,
|
maxWidth: double.infinity,
|
||||||
child: !isAdmin
|
child: !isAdmin
|
||||||
? const Center(child: Text('Admin access required.'))
|
? const Center(child: Text('Admin access required.'))
|
||||||
: officesAsync.when(
|
: Column(
|
||||||
data: (offices) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (offices.isEmpty) {
|
children: [
|
||||||
return const Center(child: Text('No offices found.'));
|
const AppPageHeader(
|
||||||
}
|
title: 'Office Management',
|
||||||
|
subtitle: 'Create and manage office locations',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: officesAsync.when(
|
||||||
|
data: (offices) {
|
||||||
|
if (offices.isEmpty) {
|
||||||
|
return const AppEmptyView(
|
||||||
|
icon: Icons.apartment_outlined,
|
||||||
|
title: 'No offices yet',
|
||||||
|
subtitle:
|
||||||
|
'Create an office to start assigning users and schedules.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final query = _searchController.text.trim().toLowerCase();
|
final query =
|
||||||
final filteredOffices = query.isEmpty
|
_searchController.text.trim().toLowerCase();
|
||||||
? offices
|
final filteredOffices = query.isEmpty
|
||||||
: offices
|
? offices
|
||||||
.where(
|
: offices
|
||||||
(office) =>
|
.where(
|
||||||
office.name.toLowerCase().contains(query) ||
|
(office) =>
|
||||||
office.id.toLowerCase().contains(query),
|
office.name
|
||||||
)
|
.toLowerCase()
|
||||||
.toList();
|
.contains(query) ||
|
||||||
|
office.id
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(query),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final listBody = TasQAdaptiveList<Office>(
|
return TasQAdaptiveList<Office>(
|
||||||
items: filteredOffices,
|
items: filteredOffices,
|
||||||
filterHeader: SizedBox(
|
filterHeader: SizedBox(
|
||||||
width: 320,
|
width: 320,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
onChanged: (_) => setState(() {}),
|
onChanged: (_) => setState(() {}),
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Search name',
|
labelText: 'Search name',
|
||||||
prefixIcon: Icon(Icons.search),
|
prefixIcon: Icon(Icons.search),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
columns: [
|
|
||||||
TasQColumn<Office>(
|
|
||||||
header: 'Office ID',
|
|
||||||
technical: true,
|
|
||||||
cellBuilder: (context, office) => Text(office.id),
|
|
||||||
),
|
|
||||||
TasQColumn<Office>(
|
|
||||||
header: 'Office Name',
|
|
||||||
cellBuilder: (context, office) => Text(office.name),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
rowActions: (office) => [
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Edit',
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () =>
|
|
||||||
_showOfficeDialog(context, ref, office: office),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
tooltip: 'Delete',
|
|
||||||
icon: const Icon(Icons.delete),
|
|
||||||
onPressed: () => _confirmDelete(context, ref, office),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
mobileTileBuilder: (context, office, actions) {
|
|
||||||
return Card(
|
|
||||||
child: ListTile(
|
|
||||||
dense: true,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
leading: const Icon(Icons.apartment_outlined),
|
|
||||||
title: Text(office.name),
|
|
||||||
subtitle: MonoText('ID ${office.id}'),
|
|
||||||
trailing: Wrap(spacing: 8, children: actions),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onRequestRefresh: () {
|
|
||||||
// For server-side pagination, update the query provider
|
|
||||||
ref.read(officesQueryProvider.notifier).state =
|
|
||||||
const OfficeQuery(offset: 0, limit: 50);
|
|
||||||
},
|
|
||||||
onPageChanged: (firstRow) {
|
|
||||||
ref
|
|
||||||
.read(officesQueryProvider.notifier)
|
|
||||||
.update((q) => q.copyWith(offset: firstRow));
|
|
||||||
},
|
|
||||||
isLoading: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.max,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'Office Management',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
columns: [
|
||||||
|
TasQColumn<Office>(
|
||||||
|
header: 'Office ID',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, office) =>
|
||||||
|
Text(office.id),
|
||||||
|
),
|
||||||
|
TasQColumn<Office>(
|
||||||
|
header: 'Office Name',
|
||||||
|
cellBuilder: (context, office) =>
|
||||||
|
Text(office.name),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
rowActions: (office) => [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Edit',
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
onPressed: () => _showOfficeDialog(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
office: office,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Delete',
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
onPressed: () =>
|
||||||
|
_confirmDelete(context, ref, office),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
mobileTileBuilder: (context, office, actions) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading:
|
||||||
|
const Icon(Icons.apartment_outlined),
|
||||||
|
title: Text(office.name),
|
||||||
|
subtitle: MonoText('ID ${office.id}'),
|
||||||
|
trailing: Wrap(spacing: 8, children: actions),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onRequestRefresh: () {
|
||||||
|
ref.read(officesQueryProvider.notifier).state =
|
||||||
|
const OfficeQuery(offset: 0, limit: 50);
|
||||||
|
},
|
||||||
|
onPageChanged: (firstRow) {
|
||||||
|
ref
|
||||||
|
.read(officesQueryProvider.notifier)
|
||||||
|
.update((q) => q.copyWith(offset: firstRow));
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => AppErrorView(
|
||||||
|
error: error,
|
||||||
|
onRetry: () => ref.invalidate(officesProvider),
|
||||||
),
|
),
|
||||||
Expanded(child: listBody),
|
),
|
||||||
],
|
),
|
||||||
);
|
],
|
||||||
},
|
|
||||||
loading: () =>
|
|
||||||
const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (error, _) =>
|
|
||||||
Center(child: Text('Failed to load offices: $error')),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (isAdmin)
|
if (isAdmin)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import '../../theme/app_surfaces.dart';
|
||||||
import '../../providers/user_offices_provider.dart';
|
import '../../providers/user_offices_provider.dart';
|
||||||
|
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
@ -105,7 +107,14 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
assignmentsAsync.error ??
|
assignmentsAsync.error ??
|
||||||
messagesAsync.error ??
|
messagesAsync.error ??
|
||||||
'Unknown error';
|
'Unknown error';
|
||||||
return Center(child: Text('Failed to load data: $error'));
|
return AppErrorView(
|
||||||
|
error: error,
|
||||||
|
onRetry: () {
|
||||||
|
ref.invalidate(profilesProvider);
|
||||||
|
ref.invalidate(officesProvider);
|
||||||
|
ref.invalidate(userOfficesProvider);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final profiles = profilesAsync.valueOrNull ?? [];
|
final profiles = profilesAsync.valueOrNull ?? [];
|
||||||
|
|
@ -124,7 +133,11 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profiles.isEmpty) {
|
if (profiles.isEmpty) {
|
||||||
return const Center(child: Text('No users found.'));
|
return const AppEmptyView(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: 'No users found',
|
||||||
|
subtitle: 'Users who sign up will appear here.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final query = _searchController.text.trim().toLowerCase();
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
|
|
@ -269,26 +282,16 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Padding(
|
return Column(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
mainAxisSize: MainAxisSize.max,
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.max,
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
const AppPageHeader(
|
||||||
children: [
|
title: 'User Management',
|
||||||
Align(
|
subtitle: 'Manage user roles and office assignments',
|
||||||
alignment: Alignment.center,
|
),
|
||||||
child: Text(
|
Expanded(child: listBody),
|
||||||
'User Management',
|
],
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(child: listBody),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
import '../../widgets/gemini_button.dart';
|
import '../../widgets/gemini_button.dart';
|
||||||
import '../../widgets/multi_select_picker.dart';
|
import '../../widgets/multi_select_picker.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class AttendanceScreen extends ConsumerStatefulWidget {
|
class AttendanceScreen extends ConsumerStatefulWidget {
|
||||||
|
|
@ -86,20 +87,9 @@ class _AttendanceScreenState extends ConsumerState<AttendanceScreen>
|
||||||
: null,
|
: null,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
title: 'Attendance',
|
||||||
child: Row(
|
subtitle: 'Check in, logbook, pass slip and leave',
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
'Attendance',
|
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
TabBar(
|
TabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
|
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _obscurePassword = true;
|
bool _obscurePassword = true;
|
||||||
|
bool _hasValidationError = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -56,7 +57,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleEmailSignIn() async {
|
Future<void> _handleEmailSignIn() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
setState(() {
|
||||||
|
_hasValidationError = !_hasValidationError;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _hasValidationError = false);
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
final auth = ref.read(authControllerProvider);
|
final auth = ref.read(authControllerProvider);
|
||||||
|
|
@ -151,7 +158,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// ── Sign-in card ──
|
// ── Sign-in card ──
|
||||||
Card(
|
M3ErrorShake(
|
||||||
|
hasError: _hasValidationError,
|
||||||
|
child: Card(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: cs.surfaceContainerLow,
|
color: cs.surfaceContainerLow,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
|
|
@ -245,6 +254,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
), // M3ErrorShake
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// ── Divider ──
|
// ── Divider ──
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import '../../providers/realtime_controller.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
|
||||||
class DashboardMetrics {
|
class DashboardMetrics {
|
||||||
|
|
@ -732,20 +733,11 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||||
|
|
||||||
final content = Column(
|
final content = Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
title: 'Dashboard',
|
||||||
child: Align(
|
subtitle: 'Live metrics and team activity',
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'Dashboard',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const _DashboardStatusBanner(),
|
const _DashboardStatusBanner(),
|
||||||
...sections,
|
...sections,
|
||||||
|
|
@ -864,12 +856,34 @@ class _DashboardStatusBanner extends ConsumerWidget {
|
||||||
|
|
||||||
if (bannerState.startsWith('error:')) {
|
if (bannerState.startsWith('error:')) {
|
||||||
final errorText = bannerState.substring(6);
|
final errorText = bannerState.substring(6);
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(
|
child: Material(
|
||||||
'Dashboard data error: $errorText',
|
color: cs.errorContainer,
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Theme.of(context).colorScheme.error,
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: cs.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
errorText,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: cs.onErrorContainer,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import '../../utils/snackbar.dart';
|
||||||
import '../../widgets/m3_card.dart';
|
import '../../widgets/m3_card.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../widgets/status_pill.dart';
|
import '../../widgets/status_pill.dart';
|
||||||
|
|
||||||
|
|
@ -103,17 +105,20 @@ class _ItServiceRequestsListScreenState
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
if (requestsAsync.hasError && !requestsAsync.hasValue) {
|
if (requestsAsync.hasError && !requestsAsync.hasValue) {
|
||||||
return Center(
|
return AppErrorView(
|
||||||
child: Text(
|
error: requestsAsync.error!,
|
||||||
'Failed to load requests: ${requestsAsync.error}',
|
onRetry: () =>
|
||||||
),
|
ref.invalidate(itServiceRequestsProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final allRequests =
|
final allRequests =
|
||||||
requestsAsync.valueOrNull ?? <ItServiceRequest>[];
|
requestsAsync.valueOrNull ?? <ItServiceRequest>[];
|
||||||
if (allRequests.isEmpty && !showSkeleton) {
|
if (allRequests.isEmpty && !showSkeleton) {
|
||||||
return const Center(
|
return const AppEmptyView(
|
||||||
child: Text('No IT service requests yet.'),
|
icon: Icons.miscellaneous_services_outlined,
|
||||||
|
title: 'No service requests yet',
|
||||||
|
subtitle:
|
||||||
|
'IT service requests submitted by your team will appear here.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
|
|
@ -146,6 +151,10 @@ class _ItServiceRequestsListScreenState
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
const AppPageHeader(
|
||||||
|
title: 'IT Service Requests',
|
||||||
|
subtitle: 'Manage and track IT support tickets',
|
||||||
|
),
|
||||||
// Status summary cards
|
// Status summary cards
|
||||||
_StatusSummaryRow(
|
_StatusSummaryRow(
|
||||||
requests: allRequests,
|
requests: allRequests,
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import '../../providers/notifications_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tasks_provider.dart';
|
import '../../providers/tasks_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/mono_text.dart';
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
|
|
@ -58,48 +60,44 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
};
|
};
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: notificationsAsync.when(
|
child: Column(
|
||||||
data: (items) {
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (items.isEmpty) {
|
children: [
|
||||||
return const Center(child: Text('No notifications yet.'));
|
const AppPageHeader(
|
||||||
}
|
title: 'Notifications',
|
||||||
return Column(
|
subtitle: 'Updates and mentions across tasks and tickets',
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
),
|
||||||
children: [
|
if (_showBanner && !_dismissed)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Text(
|
child: MaterialBanner(
|
||||||
'Notifications',
|
content: const Text(
|
||||||
style: Theme.of(
|
'Push notifications are currently silenced. Tap here to fix.',
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
),
|
||||||
),
|
actions: [
|
||||||
if (_showBanner && !_dismissed)
|
TextButton(
|
||||||
Padding(
|
onPressed: openAppSettings,
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
child: const Text('Open settings'),
|
||||||
child: MaterialBanner(
|
|
||||||
content: const Text(
|
|
||||||
'Push notifications are currently silenced. Tap here to fix.',
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
openAppSettings();
|
|
||||||
},
|
|
||||||
child: const Text('Open settings'),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() => _dismissed = true);
|
|
||||||
},
|
|
||||||
child: const Text('Dismiss'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
TextButton(
|
||||||
Expanded(
|
onPressed: () => setState(() => _dismissed = true),
|
||||||
child: ListView.separated(
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: notificationsAsync.when(
|
||||||
|
data: (items) {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return const AppEmptyView(
|
||||||
|
icon: Icons.notifications_none_outlined,
|
||||||
|
title: 'No notifications yet',
|
||||||
|
subtitle:
|
||||||
|
"You'll see updates here when something needs your attention.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
separatorBuilder: (context, index) =>
|
separatorBuilder: (context, index) =>
|
||||||
|
|
@ -124,7 +122,6 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
final title = _notificationTitle(item.type, actorName);
|
final title = _notificationTitle(item.type, actorName);
|
||||||
final icon = _notificationIcon(item.type);
|
final icon = _notificationIcon(item.type);
|
||||||
|
|
||||||
// M3 Expressive: compact card shape, no shadow.
|
|
||||||
return Card(
|
return Card(
|
||||||
shape: AppSurfaces.of(context).compactShape,
|
shape: AppSurfaces.of(context).compactShape,
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
|
@ -142,10 +139,10 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: item.isUnread
|
trailing: item.isUnread
|
||||||
? const Icon(
|
? Icon(
|
||||||
Icons.circle,
|
Icons.circle,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: Colors.red,
|
color: Theme.of(context).colorScheme.error,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
|
|
@ -174,14 +171,16 @@ class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, _) => AppErrorView(
|
||||||
|
error: error,
|
||||||
|
onRetry: () => ref.invalidate(notificationsProvider),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (error, _) =>
|
|
||||||
Center(child: Text('Failed to load notifications: $error')),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../services/face_verification.dart' as face;
|
||||||
import '../../widgets/face_verification_overlay.dart';
|
import '../../widgets/face_verification_overlay.dart';
|
||||||
import '../../widgets/multi_select_picker.dart';
|
import '../../widgets/multi_select_picker.dart';
|
||||||
import '../../widgets/qr_verification_dialog.dart';
|
import '../../widgets/qr_verification_dialog.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
||||||
|
|
@ -74,12 +75,14 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 32),
|
padding: const EdgeInsets.only(bottom: 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text('My Profile', style: Theme.of(context).textTheme.titleLarge),
|
const AppPageHeader(
|
||||||
const SizedBox(height: 12),
|
title: 'My Profile',
|
||||||
|
subtitle: 'Manage your account and preferences',
|
||||||
|
),
|
||||||
|
|
||||||
// ── Avatar Card ──
|
// ── Avatar Card ──
|
||||||
_buildAvatarCard(context, profileAsync),
|
_buildAvatarCard(context, profileAsync),
|
||||||
|
|
@ -287,19 +290,22 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
Center(
|
Center(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
Hero(
|
||||||
radius: 56,
|
tag: 'profile-avatar',
|
||||||
backgroundColor: colors.surfaceContainerHighest,
|
child: CircleAvatar(
|
||||||
backgroundImage: avatarUrl != null
|
radius: 56,
|
||||||
? NetworkImage(avatarUrl)
|
backgroundColor: colors.surfaceContainerHighest,
|
||||||
: null,
|
backgroundImage: avatarUrl != null
|
||||||
child: avatarUrl == null
|
? NetworkImage(avatarUrl)
|
||||||
? Icon(
|
: null,
|
||||||
Icons.person,
|
child: avatarUrl == null
|
||||||
size: 48,
|
? Icon(
|
||||||
color: colors.onSurfaceVariant,
|
Icons.person,
|
||||||
)
|
size: 48,
|
||||||
: null,
|
color: colors.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_uploadingAvatar)
|
if (_uploadingAvatar)
|
||||||
const Positioned.fill(
|
const Positioned.fill(
|
||||||
|
|
@ -368,7 +374,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
hasFace ? Icons.check_circle : Icons.cancel,
|
hasFace ? Icons.check_circle : Icons.cancel,
|
||||||
color: hasFace ? Colors.green : colors.error,
|
color: hasFace ? colors.tertiary : colors.error,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../providers/reports_provider.dart';
|
import '../../providers/reports_provider.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import 'report_date_filter.dart';
|
import 'report_date_filter.dart';
|
||||||
import 'report_widget_selector.dart';
|
import 'report_widget_selector.dart';
|
||||||
import 'report_pdf_export.dart';
|
import 'report_pdf_export.dart';
|
||||||
|
|
@ -69,7 +70,7 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final enabled = ref.watch(reportWidgetToggleProvider);
|
final enabled = ref.watch(reportWidgetToggleProvider);
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Column(
|
body: Column(
|
||||||
|
|
@ -82,17 +83,10 @@ class _ReportsScreenState extends ConsumerState<ReportsScreen> {
|
||||||
constraints: const BoxConstraints(maxWidth: 1200),
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Title row
|
AppPageHeader(
|
||||||
Row(
|
title: 'Reports',
|
||||||
children: [
|
subtitle: 'Analytics and performance insights',
|
||||||
Expanded(
|
actions: [
|
||||||
child: Text(
|
|
||||||
'Reports',
|
|
||||||
style: theme.textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _exporting ? null : _exportPdf,
|
onPressed: _exporting ? null : _exportPdf,
|
||||||
icon: _exporting
|
icon: _exporting
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../utils/subject_suggestions.dart';
|
import '../../utils/subject_suggestions.dart';
|
||||||
import '../../widgets/gemini_button.dart';
|
import '../../widgets/gemini_button.dart';
|
||||||
import '../../widgets/gemini_animated_text_field.dart';
|
import '../../widgets/gemini_animated_text_field.dart';
|
||||||
|
|
@ -156,17 +158,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
// Show error only when there is genuinely no data.
|
// Show error only when there is genuinely no data.
|
||||||
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
||||||
return Center(
|
return AppErrorView(
|
||||||
child: Text('Failed to load tasks: ${tasksAsync.error}'),
|
error: tasksAsync.error!,
|
||||||
|
title: 'Could not load tasks',
|
||||||
|
onRetry: () => ref.invalidate(tasksProvider),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final tasks = tasksAsync.valueOrNull ?? <Task>[];
|
final tasks = tasksAsync.valueOrNull ?? <Task>[];
|
||||||
|
|
||||||
// True empty state — data loaded but nothing returned.
|
|
||||||
if (tasks.isEmpty && !effectiveShowSkeleton) {
|
|
||||||
return const Center(child: Text('No tasks yet.'));
|
|
||||||
}
|
|
||||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
final officesSorted = List<Office>.from(offices)
|
final officesSorted = List<Office>.from(offices)
|
||||||
..sort(
|
..sort(
|
||||||
|
|
@ -479,12 +478,12 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (hasMention)
|
if (hasMention)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.circle,
|
Icons.circle,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: Colors.red,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -513,17 +512,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
title: 'Tasks',
|
||||||
child: Align(
|
subtitle: 'Work items assigned to your team',
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'Tasks',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -539,8 +530,28 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: [
|
||||||
makeList(myTasks),
|
myTasks.isEmpty && !effectiveShowSkeleton
|
||||||
makeList(filteredTasks),
|
? AppEmptyView(
|
||||||
|
icon: Icons.task_outlined,
|
||||||
|
title: _hasTaskFilters
|
||||||
|
? 'No matching tasks'
|
||||||
|
: 'No tasks assigned to you',
|
||||||
|
subtitle: _hasTaskFilters
|
||||||
|
? 'Try adjusting your filters.'
|
||||||
|
: 'Tasks assigned to you will appear here.',
|
||||||
|
)
|
||||||
|
: makeList(myTasks),
|
||||||
|
filteredTasks.isEmpty && !effectiveShowSkeleton
|
||||||
|
? AppEmptyView(
|
||||||
|
icon: Icons.task_alt_outlined,
|
||||||
|
title: _hasTaskFilters
|
||||||
|
? 'No matching tasks'
|
||||||
|
: 'No tasks yet',
|
||||||
|
subtitle: _hasTaskFilters
|
||||||
|
? 'Try adjusting your filters.'
|
||||||
|
: 'Tasks created for your team will appear here.',
|
||||||
|
)
|
||||||
|
: makeList(filteredTasks),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import '../../utils/supabase_response.dart';
|
||||||
import 'package:tasq/widgets/multi_select_picker.dart';
|
import 'package:tasq/widgets/multi_select_picker.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../widgets/tasq_adaptive_list.dart';
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
||||||
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so
|
// Note: `officesProvider` is provided globally in `tickets_provider.dart` so
|
||||||
|
|
@ -233,30 +235,19 @@ class _TeamsScreenState extends ConsumerState<TeamsScreen> {
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
title: 'IT Staff Teams',
|
||||||
child: Stack(
|
subtitle: 'Manage support teams and assignments',
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'Team Management',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(child: listBody),
|
Expanded(child: listBody),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => AppErrorView(
|
||||||
|
error: err,
|
||||||
|
onRetry: () => ref.invalidate(teamsProvider),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: M3Fab(
|
floatingActionButton: M3Fab(
|
||||||
onPressed: () => _showTeamDialog(context),
|
onPressed: () => _showTeamDialog(context),
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
|
|
||||||
class TicketsListScreen extends ConsumerStatefulWidget {
|
class TicketsListScreen extends ConsumerStatefulWidget {
|
||||||
const TicketsListScreen({super.key});
|
const TicketsListScreen({super.key});
|
||||||
|
|
@ -90,6 +92,14 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
// Build the list UI immediately so `Skeletonizer` can
|
// Build the list UI immediately so `Skeletonizer` can
|
||||||
// render placeholders while providers are still loading.
|
// render placeholders while providers are still loading.
|
||||||
|
if (ticketsAsync.hasError && !ticketsAsync.hasValue) {
|
||||||
|
return AppErrorView(
|
||||||
|
error: ticketsAsync.error!,
|
||||||
|
title: 'Could not load tickets',
|
||||||
|
onRetry: () => ref.invalidate(ticketsProvider),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final tickets = ticketsAsync.valueOrNull ?? <Ticket>[];
|
final tickets = ticketsAsync.valueOrNull ?? <Ticket>[];
|
||||||
final officeById = <String, Office>{
|
final officeById = <String, Office>{
|
||||||
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
||||||
|
|
@ -205,6 +215,31 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (filteredTickets.isEmpty && !effectiveShowSkeleton) {
|
||||||
|
final hasFilters = _hasTicketFilters;
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const AppPageHeader(
|
||||||
|
title: 'Tickets',
|
||||||
|
subtitle: 'Support requests and service tickets',
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: AppEmptyView(
|
||||||
|
icon: Icons.confirmation_number_outlined,
|
||||||
|
title: hasFilters
|
||||||
|
? 'No matching tickets'
|
||||||
|
: 'No tickets yet',
|
||||||
|
subtitle: hasFilters
|
||||||
|
? 'Try adjusting your filters.'
|
||||||
|
: 'Tickets submitted by your team will appear here.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final listBody = TasQAdaptiveList<Ticket>(
|
final listBody = TasQAdaptiveList<Ticket>(
|
||||||
items: filteredTickets,
|
items: filteredTickets,
|
||||||
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
||||||
|
|
@ -303,12 +338,12 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (hasMention)
|
if (hasMention)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(left: 8),
|
padding: const EdgeInsets.only(left: 8),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.circle,
|
Icons.circle,
|
||||||
size: 10,
|
size: 10,
|
||||||
color: Colors.red,
|
color: Theme.of(context).colorScheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -323,17 +358,9 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
mainAxisSize: MainAxisSize.max,
|
mainAxisSize: MainAxisSize.max,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
title: 'Tickets',
|
||||||
child: Align(
|
subtitle: 'Support requests and service tickets',
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
'Tickets',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(child: listBody),
|
Expanded(child: listBody),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/whereabouts_provider.dart';
|
import '../../providers/whereabouts_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../utils/app_time.dart';
|
import '../../utils/app_time.dart';
|
||||||
|
|
||||||
|
|
@ -19,12 +20,12 @@ import '../../utils/app_time.dart';
|
||||||
const _trackedRoles = {'admin', 'dispatcher', 'it_staff'};
|
const _trackedRoles = {'admin', 'dispatcher', 'it_staff'};
|
||||||
|
|
||||||
/// Role color mapping shared between map pins and legend.
|
/// Role color mapping shared between map pins and legend.
|
||||||
Color _roleColor(String? role) {
|
Color _roleColor(String? role, ColorScheme cs) {
|
||||||
return switch (role) {
|
return switch (role) {
|
||||||
'admin' => Colors.blue.shade700,
|
'admin' => cs.primary,
|
||||||
'it_staff' => Colors.green.shade700,
|
'it_staff' => cs.tertiary,
|
||||||
'dispatcher' => Colors.orange.shade700,
|
'dispatcher' => cs.secondary,
|
||||||
_ => Colors.grey,
|
_ => cs.outline,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,16 +107,11 @@ class _WhereaboutsScreenState extends ConsumerState<WhereaboutsScreen> {
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
maxWidth: 1200,
|
maxWidth: 1200,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
const AppPageHeader(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
title: 'Whereabouts',
|
||||||
child: Text(
|
subtitle: 'Live staff positions and active check-ins',
|
||||||
'Whereabouts',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
// Map
|
// Map
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -182,7 +178,8 @@ class _WhereaboutsMap extends StatelessWidget {
|
||||||
final profile = profileById[pos.userId];
|
final profile = profileById[pos.userId];
|
||||||
final name = profile?.fullName ?? 'Unknown';
|
final name = profile?.fullName ?? 'Unknown';
|
||||||
final stale = _isStale(pos.updatedAt);
|
final stale = _isStale(pos.updatedAt);
|
||||||
final pinColor = stale ? Colors.grey : _roleColor(profile?.role);
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final pinColor = stale ? cs.outlineVariant : _roleColor(profile?.role, cs);
|
||||||
return Marker(
|
return Marker(
|
||||||
point: LatLng(pos.lat, pos.lng),
|
point: LatLng(pos.lat, pos.lng),
|
||||||
width: 80,
|
width: 80,
|
||||||
|
|
@ -419,7 +416,7 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final cs = Theme.of(context).colorScheme;
|
final cs = Theme.of(context).colorScheme;
|
||||||
final roleColor = _roleColor(profile.role);
|
final roleColor = _roleColor(profile.role, cs);
|
||||||
|
|
||||||
final hasPosition = position != null;
|
final hasPosition = position != null;
|
||||||
final isInPremise = position?.inPremise ?? false;
|
final isInPremise = position?.inPremise ?? false;
|
||||||
|
|
@ -436,7 +433,7 @@ class _StaffLegendTile extends StatelessWidget {
|
||||||
|
|
||||||
final effectiveColor = (isActive || inferredInPremise)
|
final effectiveColor = (isActive || inferredInPremise)
|
||||||
? roleColor
|
? roleColor
|
||||||
: Colors.grey.shade400;
|
: cs.outlineVariant;
|
||||||
|
|
||||||
// Build status label
|
// Build status label
|
||||||
final String statusText;
|
final String statusText;
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import '../../providers/rotation_config_provider.dart';
|
||||||
import '../../providers/workforce_provider.dart';
|
import '../../providers/workforce_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/ramadan_provider.dart';
|
import '../../providers/ramadan_provider.dart';
|
||||||
|
import '../../widgets/app_page_header.dart';
|
||||||
|
import '../../widgets/app_state_view.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
|
@ -30,59 +32,71 @@ class WorkforceScreen extends ConsumerWidget {
|
||||||
role == 'admin' || role == 'programmer' || role == 'dispatcher';
|
role == 'admin' || role == 'programmer' || role == 'dispatcher';
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: LayoutBuilder(
|
child: Column(
|
||||||
builder: (context, constraints) {
|
children: [
|
||||||
final isWide = constraints.maxWidth >= 980;
|
const AppPageHeader(
|
||||||
final schedulePanel = _SchedulePanel(isAdmin: isAdmin);
|
title: 'Workforce',
|
||||||
final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin);
|
subtitle: 'Duty schedules and shift management',
|
||||||
final generatorPanel = _ScheduleGeneratorPanel(enabled: isAdmin);
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isWide = constraints.maxWidth >= 980;
|
||||||
|
final schedulePanel = _SchedulePanel(isAdmin: isAdmin);
|
||||||
|
final swapsPanel = _SwapRequestsPanel(isAdmin: isAdmin);
|
||||||
|
final generatorPanel = _ScheduleGeneratorPanel(
|
||||||
|
enabled: isAdmin,
|
||||||
|
);
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: schedulePanel),
|
Expanded(flex: 3, child: schedulePanel),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (isAdmin) generatorPanel,
|
||||||
|
if (isAdmin) const SizedBox(height: 16),
|
||||||
|
Expanded(child: swapsPanel),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DefaultTabController(
|
||||||
|
length: isAdmin ? 3 : 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
if (isAdmin) generatorPanel,
|
const SizedBox(height: 8),
|
||||||
if (isAdmin) const SizedBox(height: 16),
|
TabBar(
|
||||||
Expanded(child: swapsPanel),
|
tabs: [
|
||||||
|
const Tab(text: 'Schedule'),
|
||||||
|
const Tab(text: 'Swaps'),
|
||||||
|
if (isAdmin) const Tab(text: 'Generator'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
children: [
|
||||||
|
schedulePanel,
|
||||||
|
swapsPanel,
|
||||||
|
if (isAdmin) generatorPanel,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DefaultTabController(
|
|
||||||
length: isAdmin ? 3 : 2,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TabBar(
|
|
||||||
tabs: [
|
|
||||||
const Tab(text: 'Schedule'),
|
|
||||||
const Tab(text: 'Swaps'),
|
|
||||||
if (isAdmin) const Tab(text: 'Generator'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Expanded(
|
|
||||||
child: TabBarView(
|
|
||||||
children: [
|
|
||||||
schedulePanel,
|
|
||||||
swapsPanel,
|
|
||||||
if (isAdmin) generatorPanel,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +154,12 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (schedules.isEmpty) {
|
if (schedules.isEmpty) {
|
||||||
return const Center(child: Text('No schedules yet.'));
|
return const AppEmptyView(
|
||||||
|
icon: Icons.calendar_month_outlined,
|
||||||
|
title: 'No schedules yet',
|
||||||
|
subtitle:
|
||||||
|
'Generated schedules will appear here. Use the Generator tab to create them.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Map<String, Profile> profileById = {
|
final Map<String, Profile> profileById = {
|
||||||
|
|
@ -192,6 +211,7 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
),
|
),
|
||||||
isMine: schedule.userId == currentUserId,
|
isMine: schedule.userId == currentUserId,
|
||||||
isAdmin: isAdmin,
|
isAdmin: isAdmin,
|
||||||
|
role: profileById[schedule.userId]?.role,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -201,8 +221,10 @@ class _SchedulePanel extends ConsumerWidget {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) =>
|
error: (error, _) => AppErrorView(
|
||||||
Center(child: Text('Failed to load schedules: $error')),
|
error: error,
|
||||||
|
onRetry: () => ref.invalidate(dutySchedulesProvider),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -280,6 +302,7 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
required this.relieverLabels,
|
required this.relieverLabels,
|
||||||
required this.isMine,
|
required this.isMine,
|
||||||
required this.isAdmin,
|
required this.isAdmin,
|
||||||
|
this.role,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DutySchedule schedule;
|
final DutySchedule schedule;
|
||||||
|
|
@ -287,29 +310,27 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
final List<String> relieverLabels;
|
final List<String> relieverLabels;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
final bool isAdmin;
|
final bool isAdmin;
|
||||||
|
final String? role;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final currentUserId = ref.watch(currentUserIdProvider);
|
final currentUserId = ref.watch(currentUserIdProvider);
|
||||||
final swaps = ref.watch(swapRequestsProvider).valueOrNull ?? [];
|
// Use .select() so this tile only rebuilds when its own swap status changes,
|
||||||
|
// not every time any swap in the list is updated.
|
||||||
|
final hasRequestedSwap = ref.watch(
|
||||||
|
swapRequestsProvider.select(
|
||||||
|
(async) => (async.valueOrNull ?? const []).any(
|
||||||
|
(swap) =>
|
||||||
|
swap.requesterScheduleId == schedule.id &&
|
||||||
|
swap.requesterId == currentUserId &&
|
||||||
|
swap.status == 'pending',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
final now = AppTime.now();
|
final now = AppTime.now();
|
||||||
final isPast = schedule.startTime.isBefore(now);
|
final isPast = schedule.startTime.isBefore(now);
|
||||||
final hasRequestedSwap = swaps.any(
|
|
||||||
(swap) =>
|
|
||||||
swap.requesterScheduleId == schedule.id &&
|
|
||||||
swap.requesterId == currentUserId &&
|
|
||||||
swap.status == 'pending',
|
|
||||||
);
|
|
||||||
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
|
final canRequestSwap = isMine && schedule.status != 'absent' && !isPast;
|
||||||
|
|
||||||
final profiles = ref.watch(profilesProvider).valueOrNull ?? [];
|
|
||||||
Profile? profile;
|
|
||||||
try {
|
|
||||||
profile = profiles.firstWhere((p) => p.id == schedule.userId);
|
|
||||||
} catch (_) {
|
|
||||||
profile = null;
|
|
||||||
}
|
|
||||||
final role = profile?.role;
|
|
||||||
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
final rotationConfig = ref.watch(rotationConfigProvider).valueOrNull;
|
||||||
|
|
||||||
ShiftTypeConfig? shiftTypeConfig;
|
ShiftTypeConfig? shiftTypeConfig;
|
||||||
|
|
@ -889,15 +910,16 @@ class _ScheduleTile extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusColor(BuildContext context, String status) {
|
Color _statusColor(BuildContext context, String status) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'arrival':
|
case 'arrival':
|
||||||
return Colors.green;
|
return cs.tertiary;
|
||||||
case 'late':
|
case 'late':
|
||||||
return Colors.orange;
|
return cs.secondary;
|
||||||
case 'absent':
|
case 'absent':
|
||||||
return Colors.red;
|
return cs.error;
|
||||||
default:
|
default:
|
||||||
return Theme.of(context).colorScheme.onSurfaceVariant;
|
return cs.onSurfaceVariant;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,15 @@ import 'dart:math' as math;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// ── Reduced-motion helper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Returns true when the OS/user has requested reduced motion.
|
||||||
|
///
|
||||||
|
/// Always query this in the [build] method — never in [initState] — because
|
||||||
|
/// the value is read from [MediaQuery] which requires a valid [BuildContext].
|
||||||
|
bool m3ReducedMotion(BuildContext context) =>
|
||||||
|
MediaQuery.of(context).disableAnimations;
|
||||||
|
|
||||||
/// M3 Expressive motion constants and helpers.
|
/// M3 Expressive motion constants and helpers.
|
||||||
///
|
///
|
||||||
/// Transitions use spring-physics inspired curves with an emphasized easing
|
/// Transitions use spring-physics inspired curves with an emphasized easing
|
||||||
|
|
@ -111,6 +120,8 @@ class _M3FadeSlideInState extends State<M3FadeSlideIn>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Skip animation entirely when the OS requests reduced motion.
|
||||||
|
if (m3ReducedMotion(context)) return widget.child;
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: _opacity,
|
opacity: _opacity,
|
||||||
child: SlideTransition(position: _slide, child: widget.child),
|
child: SlideTransition(position: _slide, child: widget.child),
|
||||||
|
|
@ -514,3 +525,391 @@ Future<T?> m3ShowBottomSheet<T>({
|
||||||
builder: builder,
|
builder: builder,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// M3ShimmerBox — animated loading skeleton shimmer
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// An animated shimmer placeholder used during loading states.
|
||||||
|
///
|
||||||
|
/// The highlight sweeps from left to right at 1.2-second intervals,
|
||||||
|
/// matching the "loading skeleton" pattern from M3 guidelines.
|
||||||
|
/// Automatically falls back to a static surface when the OS requests
|
||||||
|
/// reduced motion.
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// M3ShimmerBox(width: 120, height: 14, borderRadius: BorderRadius.circular(4))
|
||||||
|
/// ```
|
||||||
|
class M3ShimmerBox extends StatefulWidget {
|
||||||
|
const M3ShimmerBox({
|
||||||
|
super.key,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.borderRadius,
|
||||||
|
this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
|
||||||
|
/// Defaults to `BorderRadius.circular(6)` when null.
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
/// Optional child rendered on top of the shimmer surface.
|
||||||
|
final Widget? child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<M3ShimmerBox> createState() => _M3ShimmerBoxState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _M3ShimmerBoxState extends State<M3ShimmerBox>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final radius = widget.borderRadius ?? BorderRadius.circular(6);
|
||||||
|
final baseColor = cs.surfaceContainerHighest;
|
||||||
|
final highlightColor = cs.surfaceContainerHigh;
|
||||||
|
|
||||||
|
if (m3ReducedMotion(context)) {
|
||||||
|
return Container(
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
decoration: BoxDecoration(color: baseColor, borderRadius: radius),
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
builder: (context, child) {
|
||||||
|
// Shimmer highlight sweeps from -0.5 → 1.5 across the widget width.
|
||||||
|
final t = _ctrl.value;
|
||||||
|
final dx = -0.5 + t * 2.0;
|
||||||
|
return Container(
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: radius,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment(dx - 0.5, 0),
|
||||||
|
end: Alignment(dx + 0.5, 0),
|
||||||
|
colors: [baseColor, highlightColor, baseColor],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// M3PressScale — press-to-scale micro-interaction
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Wraps any widget with a subtle scale-down animation on press, giving
|
||||||
|
/// tactile visual feedback for tappable surfaces (cards, list tiles, etc.).
|
||||||
|
///
|
||||||
|
/// Uses [M3Motion.micro] duration with [M3Motion.emphasizedEnter] curve
|
||||||
|
/// so the press response feels instant but controlled.
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// M3PressScale(
|
||||||
|
/// onTap: () => context.go('/detail'),
|
||||||
|
/// child: Card(child: ...),
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
class M3PressScale extends StatefulWidget {
|
||||||
|
const M3PressScale({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.scale = 0.97,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
|
||||||
|
/// The scale factor applied during a press. Defaults to `0.97`.
|
||||||
|
final double scale;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<M3PressScale> createState() => _M3PressScaleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _M3PressScaleState extends State<M3PressScale>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl;
|
||||||
|
late final Animation<double> _scaleAnim;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(vsync: this, duration: M3Motion.micro);
|
||||||
|
_scaleAnim = Tween<double>(begin: 1.0, end: widget.scale).animate(
|
||||||
|
CurvedAnimation(parent: _ctrl, curve: M3Motion.emphasizedEnter),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDown(TapDownDetails _) => _ctrl.forward();
|
||||||
|
void _onUp(TapUpDetails _) => _ctrl.reverse();
|
||||||
|
void _onCancel() => _ctrl.reverse();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Skip animation when the OS requests reduced motion.
|
||||||
|
if (m3ReducedMotion(context)) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: widget.onLongPress,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: _onDown,
|
||||||
|
onTapUp: _onUp,
|
||||||
|
onTapCancel: _onCancel,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPress: widget.onLongPress,
|
||||||
|
child: ScaleTransition(scale: _scaleAnim, child: widget.child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// M3ErrorShake — horizontal shake animation for validation errors
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Plays a brief horizontal shake animation whenever [hasError] transitions
|
||||||
|
/// from `false` to `true` — ideal for wrapping form cards or individual
|
||||||
|
/// fields to signal a validation failure.
|
||||||
|
///
|
||||||
|
/// Automatically skips the animation when the OS requests reduced motion.
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// M3ErrorShake(
|
||||||
|
/// hasError: _isLoading == false && _errorMessage != null,
|
||||||
|
/// child: Card(child: Form(...)),
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
class M3ErrorShake extends StatefulWidget {
|
||||||
|
const M3ErrorShake({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.hasError,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
/// When this transitions from `false` → `true` the shake fires once.
|
||||||
|
final bool hasError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<M3ErrorShake> createState() => _M3ErrorShakeState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _M3ErrorShakeState extends State<M3ErrorShake>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late final AnimationController _ctrl;
|
||||||
|
bool _prevError = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ctrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 450),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(M3ErrorShake oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.hasError && !_prevError) {
|
||||||
|
_ctrl.forward(from: 0);
|
||||||
|
}
|
||||||
|
_prevError = widget.hasError;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ctrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (m3ReducedMotion(context)) return widget.child;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _ctrl,
|
||||||
|
builder: (context, child) {
|
||||||
|
// Damped oscillation: amplitude fades as t → 1.
|
||||||
|
final t = _ctrl.value;
|
||||||
|
final dx = math.sin(t * math.pi * 5) * 8.0 * (1.0 - t);
|
||||||
|
return Transform.translate(offset: Offset(dx, 0), child: child);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// M3BounceIcon — entrance + idle pulse for empty/error states
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Displays an [icon] inside a colored circular badge with:
|
||||||
|
/// 1. A spring-bounce entrance animation on first build.
|
||||||
|
/// 2. A slow, gentle idle pulse (scale 1.0 → 1.06 → 1.0) that repeats
|
||||||
|
/// indefinitely to draw attention without being distracting.
|
||||||
|
///
|
||||||
|
/// Respects [m3ReducedMotion] — both animations are skipped when reduced
|
||||||
|
/// motion is enabled.
|
||||||
|
class M3BounceIcon extends StatefulWidget {
|
||||||
|
const M3BounceIcon({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.backgroundColor,
|
||||||
|
this.size = 72.0,
|
||||||
|
this.iconSize = 36.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final Color iconColor;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double size;
|
||||||
|
final double iconSize;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<M3BounceIcon> createState() => _M3BounceIconState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _M3BounceIconState extends State<M3BounceIcon>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late final AnimationController _entranceCtrl;
|
||||||
|
late final AnimationController _pulseCtrl;
|
||||||
|
late final Animation<double> _entrance;
|
||||||
|
late final Animation<double> _pulse;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Entrance: scale 0 → 1.0 with spring overshoot.
|
||||||
|
_entranceCtrl = AnimationController(vsync: this, duration: M3Motion.long);
|
||||||
|
_entrance = CurvedAnimation(parent: _entranceCtrl, curve: M3Motion.spring);
|
||||||
|
|
||||||
|
// Idle pulse: 1.0 → 1.06 → 1.0, repeating every 2.5 s.
|
||||||
|
_pulseCtrl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2500),
|
||||||
|
)..repeat(reverse: true);
|
||||||
|
_pulse = Tween<double>(begin: 1.0, end: 1.06).animate(
|
||||||
|
CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
|
||||||
|
_entranceCtrl.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_entranceCtrl.dispose();
|
||||||
|
_pulseCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final badge = Container(
|
||||||
|
width: widget.size,
|
||||||
|
height: widget.size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.backgroundColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (m3ReducedMotion(context)) return badge;
|
||||||
|
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: _entrance,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _pulse,
|
||||||
|
builder: (_, child) =>
|
||||||
|
Transform.scale(scale: _pulse.value, child: child),
|
||||||
|
child: badge,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// M3AnimatedCounter — smooth number animation
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/// Animates an integer value from its previous value to [value] using a
|
||||||
|
/// [TweenAnimationBuilder], producing a smooth counting effect on metric
|
||||||
|
/// cards and dashboard KPIs.
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// M3AnimatedCounter(
|
||||||
|
/// value: totalTasks,
|
||||||
|
/// style: theme.textTheme.headlineMedium,
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
class M3AnimatedCounter extends StatelessWidget {
|
||||||
|
const M3AnimatedCounter({
|
||||||
|
super.key,
|
||||||
|
required this.value,
|
||||||
|
this.style,
|
||||||
|
this.duration = M3Motion.standard,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int value;
|
||||||
|
final TextStyle? style;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (m3ReducedMotion(context)) {
|
||||||
|
return Text(value.toString(), style: style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TweenAnimationBuilder<int>(
|
||||||
|
tween: IntTween(begin: 0, end: value),
|
||||||
|
duration: duration,
|
||||||
|
curve: M3Motion.emphasizedEnter,
|
||||||
|
builder: (_, v, _) => Text(v.toString(), style: style),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,19 +114,39 @@ void showAwesomeSnackBar(
|
||||||
}
|
}
|
||||||
|
|
||||||
void showSuccessSnackBar(BuildContext context, String message) {
|
void showSuccessSnackBar(BuildContext context, String message) {
|
||||||
showSuccessSnackBarGlobal(message);
|
showAwesomeSnackBar(
|
||||||
|
context,
|
||||||
|
title: 'Success',
|
||||||
|
message: message,
|
||||||
|
snackType: SnackType.success,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showErrorSnackBar(BuildContext context, String message) {
|
void showErrorSnackBar(BuildContext context, String message) {
|
||||||
showErrorSnackBarGlobal(message);
|
showAwesomeSnackBar(
|
||||||
|
context,
|
||||||
|
title: 'Error',
|
||||||
|
message: message,
|
||||||
|
snackType: SnackType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showInfoSnackBar(BuildContext context, String message) {
|
void showInfoSnackBar(BuildContext context, String message) {
|
||||||
showInfoSnackBarGlobal(message);
|
showAwesomeSnackBar(
|
||||||
|
context,
|
||||||
|
title: 'Info',
|
||||||
|
message: message,
|
||||||
|
snackType: SnackType.info,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void showWarningSnackBar(BuildContext context, String message) {
|
void showWarningSnackBar(BuildContext context, String message) {
|
||||||
showWarningSnackBarGlobal(message);
|
showAwesomeSnackBar(
|
||||||
|
context,
|
||||||
|
title: 'Warning',
|
||||||
|
message: message,
|
||||||
|
snackType: SnackType.warning,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.
|
/// Global helpers that use the app-level `scaffoldMessengerKey` directly.
|
||||||
|
|
|
||||||
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,
|
fullName: displayName,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
|
heroTag: 'profile-avatar',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(displayName),
|
Text(displayName),
|
||||||
|
|
@ -104,6 +105,7 @@ class AppScaffold extends ConsumerWidget {
|
||||||
fullName: displayName,
|
fullName: displayName,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
|
heroTag: 'profile-avatar',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -273,19 +275,42 @@ class _NotificationBell extends ConsumerWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final unreadCount = ref.watch(unreadNotificationsCountProvider);
|
final unreadCount = ref.watch(unreadNotificationsCountProvider);
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
return IconButton(
|
return IconButton(
|
||||||
tooltip: 'Notifications',
|
tooltip: 'Notifications',
|
||||||
onPressed: () => context.go('/notifications'),
|
onPressed: () => context.go('/notifications'),
|
||||||
icon: Stack(
|
icon: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.notifications),
|
const Icon(Icons.notifications_outlined),
|
||||||
if (unreadCount > 0)
|
AnimatedSwitcher(
|
||||||
const Positioned(
|
duration: const Duration(milliseconds: 200),
|
||||||
right: -2,
|
switchInCurve: Curves.easeOutBack,
|
||||||
top: -2,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
child: Icon(Icons.circle, size: 10, color: Colors.red),
|
transitionBuilder: (child, animation) => ScaleTransition(
|
||||||
|
scale: animation,
|
||||||
|
child: child,
|
||||||
),
|
),
|
||||||
|
child: unreadCount > 0
|
||||||
|
? Positioned(
|
||||||
|
key: const ValueKey('badge'),
|
||||||
|
right: -3,
|
||||||
|
top: -3,
|
||||||
|
child: Container(
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.error,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: cs.surface,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(key: ValueKey('no-badge')),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -422,24 +447,12 @@ List<NavSection> _buildSections(String role) {
|
||||||
icon: Icons.apartment_outlined,
|
icon: Icons.apartment_outlined,
|
||||||
selectedIcon: Icons.apartment,
|
selectedIcon: Icons.apartment,
|
||||||
),
|
),
|
||||||
NavItem(
|
|
||||||
label: 'Geofence test',
|
|
||||||
route: '/settings/geofence-test',
|
|
||||||
icon: Icons.map_outlined,
|
|
||||||
selectedIcon: Icons.map,
|
|
||||||
),
|
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'IT Staff Teams',
|
label: 'IT Staff Teams',
|
||||||
route: '/settings/teams',
|
route: '/settings/teams',
|
||||||
icon: Icons.groups_2_outlined,
|
icon: Icons.groups_2_outlined,
|
||||||
selectedIcon: Icons.groups_2,
|
selectedIcon: Icons.groups_2,
|
||||||
),
|
),
|
||||||
NavItem(
|
|
||||||
label: 'Permissions',
|
|
||||||
route: '/settings/permissions',
|
|
||||||
icon: Icons.lock_open,
|
|
||||||
selectedIcon: Icons.lock,
|
|
||||||
),
|
|
||||||
if (kIsWeb) ...[
|
if (kIsWeb) ...[
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'App Update',
|
label: 'App Update',
|
||||||
|
|
@ -459,19 +472,16 @@ List<NavSection> _buildSections(String role) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// non-admin users still get a simple Settings section containing only
|
|
||||||
// permissions. this keeps the screen accessible without exposing the
|
|
||||||
// administrative management screens.
|
|
||||||
return [
|
return [
|
||||||
NavSection(label: 'Operations', items: mainItems),
|
NavSection(label: 'Operations', items: mainItems),
|
||||||
NavSection(
|
NavSection(
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
items: [
|
items: [
|
||||||
NavItem(
|
NavItem(
|
||||||
label: 'Permissions',
|
label: 'Logout',
|
||||||
route: '/settings/permissions',
|
route: '',
|
||||||
icon: Icons.lock_open,
|
icon: Icons.logout,
|
||||||
selectedIcon: Icons.lock,
|
isLogout: true,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
|
||||||
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:
|
/// Native Flutter profile avatar that displays either:
|
||||||
/// 1. User's avatar image URL (if provided)
|
/// 1. User's avatar image URL (if provided)
|
||||||
/// 2. Initials derived from full name (fallback)
|
/// 2. Initials derived from full name (fallback)
|
||||||
|
///
|
||||||
|
/// Pass [heroTag] to participate in a Hero transition to/from a destination
|
||||||
|
/// that uses the same tag (e.g., the profile screen's large avatar).
|
||||||
class ProfileAvatar extends StatelessWidget {
|
class ProfileAvatar extends StatelessWidget {
|
||||||
const ProfileAvatar({
|
const ProfileAvatar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.fullName,
|
required this.fullName,
|
||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.radius = 18,
|
this.radius = 18,
|
||||||
|
this.heroTag,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String fullName;
|
final String fullName;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
final double radius;
|
final double radius;
|
||||||
|
|
||||||
|
/// When non-null, wraps the avatar in a [Hero] with this tag.
|
||||||
|
final Object? heroTag;
|
||||||
|
|
||||||
String _getInitials() {
|
String _getInitials() {
|
||||||
final trimmed = fullName.trim();
|
final trimmed = fullName.trim();
|
||||||
if (trimmed.isEmpty) return 'U';
|
if (trimmed.isEmpty) return 'U';
|
||||||
|
|
@ -28,37 +35,37 @@ class ProfileAvatar extends StatelessWidget {
|
||||||
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
return '${parts.first[0]}${parts.last[0]}'.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getInitialsColor(String initials) {
|
/// Returns a (background, foreground) pair from the M3 tonal palette.
|
||||||
// Generate a deterministic color based on initials
|
///
|
||||||
|
/// Uses a deterministic hash of the initials to cycle through the scheme's
|
||||||
|
/// semantic container colors so every avatar is theme-aware and accessible.
|
||||||
|
(Color, Color) _getTonalColors(String initials, ColorScheme cs) {
|
||||||
final hash =
|
final hash =
|
||||||
initials.codeUnitAt(0) +
|
initials.codeUnitAt(0) +
|
||||||
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
|
(initials.length > 1 ? initials.codeUnitAt(1) * 256 : 0);
|
||||||
final colors = [
|
|
||||||
Colors.red,
|
// Six M3-compliant container pairs (background / on-color text).
|
||||||
Colors.pink,
|
final pairs = [
|
||||||
Colors.purple,
|
(cs.primaryContainer, cs.onPrimaryContainer),
|
||||||
Colors.deepPurple,
|
(cs.secondaryContainer, cs.onSecondaryContainer),
|
||||||
Colors.indigo,
|
(cs.tertiaryContainer, cs.onTertiaryContainer),
|
||||||
Colors.blue,
|
(cs.errorContainer, cs.onErrorContainer),
|
||||||
Colors.lightBlue,
|
(cs.primary, cs.onPrimary),
|
||||||
Colors.cyan,
|
(cs.secondary, cs.onSecondary),
|
||||||
Colors.teal,
|
|
||||||
Colors.green,
|
|
||||||
Colors.lightGreen,
|
|
||||||
Colors.orange,
|
|
||||||
Colors.deepOrange,
|
|
||||||
Colors.brown,
|
|
||||||
];
|
];
|
||||||
return colors[hash % colors.length];
|
|
||||||
|
return pairs[hash % pairs.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final initials = _getInitials();
|
final initials = _getInitials();
|
||||||
|
|
||||||
|
Widget avatar;
|
||||||
|
|
||||||
// If avatar URL is provided, attempt to load the image
|
// If avatar URL is provided, attempt to load the image
|
||||||
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
if (avatarUrl != null && avatarUrl!.isNotEmpty) {
|
||||||
return CircleAvatar(
|
avatar = CircleAvatar(
|
||||||
radius: radius,
|
radius: radius,
|
||||||
backgroundImage: NetworkImage(avatarUrl!),
|
backgroundImage: NetworkImage(avatarUrl!),
|
||||||
onBackgroundImageError: (_, _) {
|
onBackgroundImageError: (_, _) {
|
||||||
|
|
@ -66,20 +73,30 @@ class ProfileAvatar extends StatelessWidget {
|
||||||
},
|
},
|
||||||
child: null, // Image will display if loaded successfully
|
child: null, // Image will display if loaded successfully
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
final (bg, fg) = _getTonalColors(
|
||||||
|
initials,
|
||||||
|
Theme.of(context).colorScheme,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback to initials
|
||||||
|
avatar = CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: bg,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
color: fg,
|
||||||
|
fontSize: radius * 0.8,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to initials
|
if (heroTag != null) {
|
||||||
return CircleAvatar(
|
return Hero(tag: heroTag!, child: avatar);
|
||||||
radius: radius,
|
}
|
||||||
backgroundColor: _getInitialsColor(initials),
|
return avatar;
|
||||||
child: Text(
|
|
||||||
initials,
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: radius * 0.8,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,14 @@ class StatusPill extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final scheme = Theme.of(context).colorScheme;
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
// isEmphasized uses primaryContainer (higher tonal weight) to visually
|
||||||
|
// distinguish high-priority statuses from routine ones.
|
||||||
final background = isEmphasized
|
final background = isEmphasized
|
||||||
? scheme.tertiaryContainer
|
? scheme.primaryContainer
|
||||||
: scheme.tertiaryContainer;
|
: scheme.tertiaryContainer;
|
||||||
final foreground = scheme.onTertiaryContainer;
|
final foreground = isEmphasized
|
||||||
|
? scheme.onPrimaryContainer
|
||||||
|
: scheme.onTertiaryContainer;
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../theme/app_typography.dart';
|
import '../theme/app_typography.dart';
|
||||||
import '../theme/app_surfaces.dart';
|
import '../theme/app_surfaces.dart';
|
||||||
|
import '../theme/m3_motion.dart';
|
||||||
import 'mono_text.dart';
|
import 'mono_text.dart';
|
||||||
|
|
||||||
/// A column configuration for the [TasQAdaptiveList] desktop table view.
|
/// A column configuration for the [TasQAdaptiveList] desktop table view.
|
||||||
|
|
@ -205,11 +206,18 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
return _MobileTile(
|
// M3 Expressive: stagger first 8 items on enter (50 ms per step).
|
||||||
item: item,
|
final staggerDelay = Duration(
|
||||||
actions: actions,
|
milliseconds: math.min(index, 8) * 50,
|
||||||
mobileTileBuilder: mobileTileBuilder,
|
);
|
||||||
onRowTap: onRowTap,
|
return M3FadeSlideIn(
|
||||||
|
delay: staggerDelay,
|
||||||
|
child: _MobileTile(
|
||||||
|
item: item,
|
||||||
|
actions: actions,
|
||||||
|
mobileTileBuilder: mobileTileBuilder,
|
||||||
|
onRowTap: onRowTap,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -225,11 +233,17 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
}
|
}
|
||||||
final item = items[index];
|
final item = items[index];
|
||||||
final actions = rowActions?.call(item) ?? const <Widget>[];
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
return _MobileTile(
|
final staggerDelay = Duration(
|
||||||
item: item,
|
milliseconds: math.min(index, 8) * 50,
|
||||||
actions: actions,
|
);
|
||||||
mobileTileBuilder: mobileTileBuilder,
|
return M3FadeSlideIn(
|
||||||
onRowTap: onRowTap,
|
delay: staggerDelay,
|
||||||
|
child: _MobileTile(
|
||||||
|
item: item,
|
||||||
|
actions: actions,
|
||||||
|
mobileTileBuilder: mobileTileBuilder,
|
||||||
|
onRowTap: onRowTap,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
|
|
@ -294,35 +308,29 @@ class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
Widget _loadingTile(BuildContext context) {
|
Widget _loadingTile(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: SizedBox(
|
child: Card(
|
||||||
height: 72,
|
margin: EdgeInsets.zero,
|
||||||
child: Card(
|
child: Padding(
|
||||||
margin: EdgeInsets.zero,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Padding(
|
child: Row(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
children: [
|
||||||
child: Row(
|
M3ShimmerBox(
|
||||||
children: [
|
width: 40,
|
||||||
Container(
|
height: 40,
|
||||||
width: 40,
|
borderRadius: BorderRadius.circular(6),
|
||||||
height: 40,
|
),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(width: 12),
|
||||||
color: Colors.white,
|
Expanded(
|
||||||
borderRadius: BorderRadius.circular(6),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
M3ShimmerBox(height: 12),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
M3ShimmerBox(width: 150, height: 10),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(
|
],
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(height: 12, color: Colors.white),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(height: 10, width: 150, color: Colors.white),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -151,11 +151,13 @@ class _UpdateDialogState extends State<UpdateDialog> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (_failed)
|
if (_failed)
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'An error occurred while downloading. Please try again.',
|
'An error occurred while downloading. Please try again.',
|
||||||
style: TextStyle(color: Colors.red),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user