Major UI overhaul
This commit is contained in:
parent
f4dea74394
commit
01c6b3537c
|
|
@ -5,19 +5,35 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../../models/office.dart';
|
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 '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
||||||
class OfficesScreen extends ConsumerWidget {
|
class OfficesScreen extends ConsumerStatefulWidget {
|
||||||
const OfficesScreen({super.key});
|
const OfficesScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<OfficesScreen> createState() => _OfficesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OfficesScreenState extends ConsumerState<OfficesScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final isAdmin = ref.watch(isAdminProvider);
|
final isAdmin = ref.watch(isAdminProvider);
|
||||||
final officesAsync = ref.watch(officesProvider);
|
final officesAsync = ref.watch(officesProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Stack(
|
||||||
body: ResponsiveBody(
|
children: [
|
||||||
maxWidth: 800,
|
ResponsiveBody(
|
||||||
|
maxWidth: double.infinity,
|
||||||
child: !isAdmin
|
child: !isAdmin
|
||||||
? const Center(child: Text('Admin access required.'))
|
? const Center(child: Text('Admin access required.'))
|
||||||
: officesAsync.when(
|
: officesAsync.when(
|
||||||
|
|
@ -25,78 +41,122 @@ class OfficesScreen extends ConsumerWidget {
|
||||||
if (offices.isEmpty) {
|
if (offices.isEmpty) {
|
||||||
return const Center(child: Text('No offices found.'));
|
return const Center(child: Text('No offices found.'));
|
||||||
}
|
}
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
children: [
|
final filteredOffices = query.isEmpty
|
||||||
Padding(
|
? offices
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
: offices
|
||||||
child: Row(
|
.where(
|
||||||
children: [
|
(office) =>
|
||||||
Expanded(
|
office.name.toLowerCase().contains(query) ||
|
||||||
child: Text(
|
office.id.toLowerCase().contains(query),
|
||||||
'Office Management',
|
)
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
.toList();
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
|
final listBody = TasQAdaptiveList<Office>(
|
||||||
|
items: filteredOffices,
|
||||||
|
filterHeader: SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
onChanged: (_) => setState(() {}),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Search name',
|
||||||
|
prefixIcon: Icon(Icons.search),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextButton.icon(
|
),
|
||||||
onPressed: () => context.go('/settings/users'),
|
columns: [
|
||||||
icon: const Icon(Icons.group),
|
TasQColumn<Office>(
|
||||||
label: const Text('User access'),
|
header: 'Office ID',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, office) => Text(office.id),
|
||||||
|
),
|
||||||
|
TasQColumn<Office>(
|
||||||
|
header: 'Office Name',
|
||||||
|
cellBuilder: (context, office) => Text(office.name),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
rowActions: (office) => [
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.separated(
|
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
|
||||||
itemCount: offices.length,
|
|
||||||
separatorBuilder: (_, index) =>
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final office = offices[index];
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.apartment_outlined),
|
|
||||||
title: Text(office.name),
|
|
||||||
trailing: Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
children: [
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Edit',
|
tooltip: 'Edit',
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.edit),
|
||||||
onPressed: () => _showOfficeDialog(
|
onPressed: () =>
|
||||||
context,
|
_showOfficeDialog(context, ref, office: office),
|
||||||
ref,
|
|
||||||
office: office,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Delete',
|
tooltip: 'Delete',
|
||||||
icon: const Icon(Icons.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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: TextButton.icon(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
_confirmDelete(context, ref, office),
|
context.go('/settings/users'),
|
||||||
),
|
icon: const Icon(Icons.group),
|
||||||
],
|
label: const Text('User access'),
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: listBody),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () =>
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
error: (error, _) =>
|
error: (error, _) =>
|
||||||
Center(child: Text('Failed to load offices: $error')),
|
Center(child: Text('Failed to load offices: $error')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: isAdmin
|
if (isAdmin)
|
||||||
? FloatingActionButton.extended(
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
onPressed: () => _showOfficeDialog(context, ref),
|
onPressed: () => _showOfficeDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Office'),
|
label: const Text('New Office'),
|
||||||
)
|
),
|
||||||
: null,
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@ import '../../providers/admin_user_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/user_offices_provider.dart';
|
import '../../providers/user_offices_provider.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
|
|
||||||
class UserManagementScreen extends ConsumerStatefulWidget {
|
class UserManagementScreen extends ConsumerStatefulWidget {
|
||||||
const UserManagementScreen({super.key});
|
const UserManagementScreen({super.key});
|
||||||
|
|
@ -28,6 +30,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
];
|
];
|
||||||
|
|
||||||
final _fullNameController = TextEditingController();
|
final _fullNameController = TextEditingController();
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
String? _selectedUserId;
|
String? _selectedUserId;
|
||||||
String? _selectedRole;
|
String? _selectedRole;
|
||||||
|
|
@ -43,6 +46,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_fullNameController.dispose();
|
_fullNameController.dispose();
|
||||||
|
_searchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,9 +58,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||||
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return ResponsiveBody(
|
||||||
body: ResponsiveBody(
|
maxWidth: double.infinity,
|
||||||
maxWidth: 1080,
|
|
||||||
child: !isAdmin
|
child: !isAdmin
|
||||||
? const Center(child: Text('Admin access required.'))
|
? const Center(child: Text('Admin access required.'))
|
||||||
: _buildContent(
|
: _buildContent(
|
||||||
|
|
@ -66,7 +69,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
assignmentsAsync,
|
assignmentsAsync,
|
||||||
messagesAsync,
|
messagesAsync,
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,45 +116,19 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'User Management',
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: _buildUserTable(
|
|
||||||
context,
|
|
||||||
profiles,
|
|
||||||
offices,
|
|
||||||
assignments,
|
|
||||||
lastActiveByUser,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildUserTable(
|
|
||||||
BuildContext context,
|
|
||||||
List<Profile> profiles,
|
|
||||||
List<Office> offices,
|
|
||||||
List<UserOffice> assignments,
|
|
||||||
Map<String, DateTime> lastActiveByUser,
|
|
||||||
) {
|
|
||||||
if (profiles.isEmpty) {
|
if (profiles.isEmpty) {
|
||||||
return const Center(child: Text('No users found.'));
|
return const Center(child: Text('No users found.'));
|
||||||
}
|
}
|
||||||
final officeNameById = {
|
|
||||||
for (final office in offices) office.id: office.name,
|
final query = _searchController.text.trim().toLowerCase();
|
||||||
};
|
final filteredProfiles = query.isEmpty
|
||||||
|
? profiles
|
||||||
|
: profiles.where((profile) {
|
||||||
|
final label =
|
||||||
|
profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||||
|
return label.toLowerCase().contains(query) ||
|
||||||
|
profile.id.toLowerCase().contains(query);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
final officeCountByUser = <String, int>{};
|
final officeCountByUser = <String, int>{};
|
||||||
for (final assignment in assignments) {
|
for (final assignment in assignments) {
|
||||||
|
|
@ -163,140 +139,127 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Material(
|
final listBody = TasQAdaptiveList<Profile>(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
items: filteredProfiles,
|
||||||
child: SingleChildScrollView(
|
filterHeader: SizedBox(
|
||||||
scrollDirection: Axis.horizontal,
|
width: 320,
|
||||||
child: ConstrainedBox(
|
child: TextField(
|
||||||
constraints: const BoxConstraints(minWidth: 720),
|
controller: _searchController,
|
||||||
child: SingleChildScrollView(
|
onChanged: (_) => setState(() {}),
|
||||||
child: DataTable(
|
decoration: const InputDecoration(
|
||||||
headingRowHeight: 46,
|
labelText: 'Search name',
|
||||||
dataRowMinHeight: 48,
|
prefixIcon: Icon(Icons.search),
|
||||||
dataRowMaxHeight: 64,
|
|
||||||
columnSpacing: 24,
|
|
||||||
horizontalMargin: 16,
|
|
||||||
dividerThickness: 1,
|
|
||||||
headingRowColor: WidgetStateProperty.resolveWith(
|
|
||||||
(states) =>
|
|
||||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
),
|
),
|
||||||
columns: const [
|
),
|
||||||
DataColumn(label: Text('User')),
|
),
|
||||||
DataColumn(label: Text('Email')),
|
columns: [
|
||||||
DataColumn(label: Text('Role')),
|
TasQColumn<Profile>(
|
||||||
DataColumn(label: Text('Offices')),
|
header: 'User',
|
||||||
DataColumn(label: Text('Status')),
|
cellBuilder: (context, profile) {
|
||||||
DataColumn(label: Text('Last active')),
|
final label =
|
||||||
],
|
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||||
rows: profiles.asMap().entries.map((entry) {
|
return Text(label);
|
||||||
final index = entry.key;
|
},
|
||||||
final profile = entry.value;
|
),
|
||||||
final label = profile.fullName.isEmpty
|
TasQColumn<Profile>(
|
||||||
? profile.id
|
header: 'Email',
|
||||||
: profile.fullName;
|
cellBuilder: (context, profile) {
|
||||||
|
final status = _statusCache[profile.id];
|
||||||
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
|
final email =
|
||||||
|
hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
|
||||||
|
return Text(email);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TasQColumn<Profile>(
|
||||||
|
header: 'Role',
|
||||||
|
cellBuilder: (context, profile) => Text(profile.role),
|
||||||
|
),
|
||||||
|
TasQColumn<Profile>(
|
||||||
|
header: 'Offices',
|
||||||
|
cellBuilder: (context, profile) {
|
||||||
|
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
||||||
|
return Text(officesAssigned == 0 ? 'None' : '$officesAssigned');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TasQColumn<Profile>(
|
||||||
|
header: 'Status',
|
||||||
|
cellBuilder: (context, profile) {
|
||||||
final status = _statusCache[profile.id];
|
final status = _statusCache[profile.id];
|
||||||
final hasError = _statusErrors.contains(profile.id);
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
final isLoading = _statusLoading.contains(profile.id);
|
final isLoading = _statusLoading.contains(profile.id);
|
||||||
final email = hasError
|
final statusLabel =
|
||||||
? 'Unavailable'
|
_userStatusLabel(status, hasError, isLoading);
|
||||||
: (status?.email ?? (isLoading ? 'Loading...' : 'N/A'));
|
return _StatusBadge(label: statusLabel);
|
||||||
final statusLabel = hasError
|
},
|
||||||
? 'Unavailable'
|
),
|
||||||
: (status == null
|
TasQColumn<Profile>(
|
||||||
? (isLoading ? 'Loading...' : 'Unknown')
|
header: 'Last active',
|
||||||
: (status.isLocked ? 'Locked' : 'Active'));
|
cellBuilder: (context, profile) {
|
||||||
final officeCount = officeCountByUser[profile.id] ?? 0;
|
final lastActive = lastActiveByUser[profile.id];
|
||||||
final officeLabel = officeCount == 0 ? 'None' : '$officeCount';
|
return Text(_formatLastActiveLabel(lastActive));
|
||||||
final officeNames = assignments
|
|
||||||
.where((assignment) => assignment.userId == profile.id)
|
|
||||||
.map(
|
|
||||||
(assignment) =>
|
|
||||||
officeNameById[assignment.officeId] ??
|
|
||||||
assignment.officeId,
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
final officesText = officeNames.isEmpty
|
|
||||||
? 'No offices'
|
|
||||||
: officeNames.join(', ');
|
|
||||||
final lastActive = _formatLastActive(
|
|
||||||
lastActiveByUser[profile.id]?.toLocal(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return DataRow.byIndex(
|
|
||||||
index: index,
|
|
||||||
onSelectChanged: (selected) {
|
|
||||||
if (selected != true) return;
|
|
||||||
_showUserDialog(context, profile, offices, assignments);
|
|
||||||
},
|
},
|
||||||
color: WidgetStateProperty.resolveWith((states) {
|
|
||||||
if (states.contains(WidgetState.selected)) {
|
|
||||||
return Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceTint.withValues(alpha: 0.12);
|
|
||||||
}
|
|
||||||
if (index.isEven) {
|
|
||||||
return Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surface.withValues(alpha: 0.6);
|
|
||||||
}
|
|
||||||
return Theme.of(context).colorScheme.surface;
|
|
||||||
}),
|
|
||||||
cells: [
|
|
||||||
DataCell(Text(label)),
|
|
||||||
DataCell(Text(email)),
|
|
||||||
DataCell(Text(profile.role)),
|
|
||||||
DataCell(
|
|
||||||
Tooltip(message: officesText, child: Text(officeLabel)),
|
|
||||||
),
|
),
|
||||||
DataCell(Text(statusLabel)),
|
|
||||||
DataCell(Text(lastActive)),
|
|
||||||
],
|
],
|
||||||
);
|
onRowTap: (profile) =>
|
||||||
}).toList(),
|
_showUserDialog(context, profile, offices, assignments),
|
||||||
),
|
mobileTileBuilder: (context, profile, actions) {
|
||||||
),
|
final label =
|
||||||
|
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||||
|
final status = _statusCache[profile.id];
|
||||||
|
final hasError = _statusErrors.contains(profile.id);
|
||||||
|
final isLoading = _statusLoading.contains(profile.id);
|
||||||
|
final email = hasError ? 'Unavailable' : (status?.email ?? 'Unknown');
|
||||||
|
final officesAssigned = officeCountByUser[profile.id] ?? 0;
|
||||||
|
final lastActive = lastActiveByUser[profile.id];
|
||||||
|
final statusLabel = _userStatusLabel(status, hasError, isLoading);
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
title: Text(label),
|
||||||
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('Role: ${profile.role}'),
|
||||||
|
Text('Offices: $officesAssigned'),
|
||||||
|
Text('Last active: ${_formatLastActiveLabel(lastActive)}'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MonoText('ID ${profile.id}'),
|
||||||
|
Text('Email: $email'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
trailing: _StatusBadge(label: statusLabel),
|
||||||
|
onTap: () =>
|
||||||
|
_showUserDialog(context, profile, offices, assignments),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void _ensureStatusLoaded(String userId) {
|
return Padding(
|
||||||
if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
return;
|
child: Column(
|
||||||
}
|
mainAxisSize: MainAxisSize.max,
|
||||||
_statusLoading.add(userId);
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
_statusErrors.remove(userId);
|
children: [
|
||||||
ref
|
Align(
|
||||||
.read(adminUserControllerProvider)
|
alignment: Alignment.center,
|
||||||
.fetchStatus(userId)
|
child: Text(
|
||||||
.then((status) {
|
'User Management',
|
||||||
if (!mounted) return;
|
textAlign: TextAlign.center,
|
||||||
setState(() {
|
style: Theme.of(
|
||||||
_statusCache[userId] = status;
|
context,
|
||||||
_statusLoading.remove(userId);
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
});
|
),
|
||||||
})
|
),
|
||||||
.catchError((_) {
|
const SizedBox(height: 16),
|
||||||
if (!mounted) return;
|
Expanded(child: listBody),
|
||||||
setState(() {
|
],
|
||||||
_statusLoading.remove(userId);
|
),
|
||||||
_statusErrors.add(userId);
|
);
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _prefetchStatuses(List<Profile> profiles) {
|
|
||||||
final ids = profiles.map((profile) => profile.id).toSet();
|
|
||||||
final missing = ids.difference(_prefetchedUserIds);
|
|
||||||
if (missing.isEmpty) return;
|
|
||||||
_prefetchedUserIds = ids;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
for (final userId in missing) {
|
|
||||||
_ensureStatusLoaded(userId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showUserDialog(
|
Future<void> _showUserDialog(
|
||||||
|
|
@ -344,6 +307,44 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _ensureStatusLoaded(String userId) {
|
||||||
|
if (_statusCache.containsKey(userId) || _statusLoading.contains(userId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_statusLoading.add(userId);
|
||||||
|
_statusErrors.remove(userId);
|
||||||
|
ref
|
||||||
|
.read(adminUserControllerProvider)
|
||||||
|
.fetchStatus(userId)
|
||||||
|
.then((status) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_statusCache[userId] = status;
|
||||||
|
_statusLoading.remove(userId);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catchError((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_statusLoading.remove(userId);
|
||||||
|
_statusErrors.add(userId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _prefetchStatuses(List<Profile> profiles) {
|
||||||
|
final ids = profiles.map((profile) => profile.id).toSet();
|
||||||
|
final missing = ids.difference(_prefetchedUserIds);
|
||||||
|
if (missing.isEmpty) return;
|
||||||
|
_prefetchedUserIds = ids;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
for (final userId in missing) {
|
||||||
|
_ensureStatusLoaded(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildUserForm(
|
Widget _buildUserForm(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Profile profile,
|
Profile profile,
|
||||||
|
|
@ -657,8 +658,20 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _formatLastActive(DateTime? value) {
|
String _userStatusLabel(
|
||||||
|
AdminUserStatus? status,
|
||||||
|
bool hasError,
|
||||||
|
bool isLoading,
|
||||||
|
) {
|
||||||
|
if (isLoading) return 'Loading';
|
||||||
|
if (hasError) return 'Status error';
|
||||||
|
if (status == null) return 'Unknown';
|
||||||
|
return status.isLocked ? 'Locked' : 'Active';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLastActiveLabel(DateTime? value) {
|
||||||
if (value == null) return 'N/A';
|
if (value == null) return 'N/A';
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final diff = now.difference(value);
|
final diff = now.difference(value);
|
||||||
|
|
@ -670,4 +683,33 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
||||||
final day = value.day.toString().padLeft(2, '0');
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
return '${value.year}-$month-$day';
|
return '${value.year}-$month-$day';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
const _StatusBadge({required this.label});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final isError = label.toLowerCase().contains('error');
|
||||||
|
final background = isError
|
||||||
|
? scheme.errorContainer
|
||||||
|
: scheme.secondaryContainer;
|
||||||
|
final foreground = isError
|
||||||
|
? scheme.onErrorContainer
|
||||||
|
: scheme.onSecondaryContainer;
|
||||||
|
|
||||||
|
return Badge(
|
||||||
|
backgroundColor: background,
|
||||||
|
label: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ 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/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
|
import '../../widgets/status_pill.dart';
|
||||||
|
|
||||||
class DashboardMetrics {
|
class DashboardMetrics {
|
||||||
DashboardMetrics({
|
DashboardMetrics({
|
||||||
|
|
@ -265,8 +267,63 @@ class DashboardScreen extends StatelessWidget {
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth >= 980;
|
final sections = <Widget>[
|
||||||
final metricsColumn = Column(
|
const SizedBox(height: 16),
|
||||||
|
_sectionTitle(context, 'IT Staff Pulse'),
|
||||||
|
const _StaffTable(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionTitle(context, 'Core Daily KPIs'),
|
||||||
|
_cardGrid(context, [
|
||||||
|
_MetricCard(
|
||||||
|
title: 'New tickets today',
|
||||||
|
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Closed today',
|
||||||
|
valueBuilder: (metrics) => metrics.closedToday.toString(),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Open tickets',
|
||||||
|
valueBuilder: (metrics) => metrics.openTickets.toString(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionTitle(context, 'Task Flow'),
|
||||||
|
_cardGrid(context, [
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Tasks created',
|
||||||
|
valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Tasks completed',
|
||||||
|
valueBuilder: (metrics) =>
|
||||||
|
metrics.tasksCompletedToday.toString(),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Open tasks',
|
||||||
|
valueBuilder: (metrics) => metrics.openTasks.toString(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_sectionTitle(context, 'TAT / Response'),
|
||||||
|
_cardGrid(context, [
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Avg response',
|
||||||
|
valueBuilder: (metrics) => _formatDuration(metrics.avgResponse),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Avg triage',
|
||||||
|
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
|
||||||
|
),
|
||||||
|
_MetricCard(
|
||||||
|
title: 'Longest response',
|
||||||
|
valueBuilder: (metrics) =>
|
||||||
|
_formatDuration(metrics.longestResponse),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
final content = Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
@ -284,113 +341,16 @@ class DashboardScreen extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const _DashboardStatusBanner(),
|
const _DashboardStatusBanner(),
|
||||||
_sectionTitle(context, 'Core Daily KPIs'),
|
...sections,
|
||||||
_cardGrid(context, [
|
|
||||||
_MetricCard(
|
|
||||||
title: 'New tickets today',
|
|
||||||
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Closed today',
|
|
||||||
valueBuilder: (metrics) => metrics.closedToday.toString(),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Open tickets',
|
|
||||||
valueBuilder: (metrics) => metrics.openTickets.toString(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_sectionTitle(context, 'TAT / Response'),
|
|
||||||
_cardGrid(context, [
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Avg response',
|
|
||||||
valueBuilder: (metrics) =>
|
|
||||||
_formatDuration(metrics.avgResponse),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Avg triage',
|
|
||||||
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Longest response',
|
|
||||||
valueBuilder: (metrics) =>
|
|
||||||
_formatDuration(metrics.longestResponse),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
_sectionTitle(context, 'Task Flow'),
|
|
||||||
_cardGrid(context, [
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Tasks created',
|
|
||||||
valueBuilder: (metrics) =>
|
|
||||||
metrics.tasksCreatedToday.toString(),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Tasks completed',
|
|
||||||
valueBuilder: (metrics) =>
|
|
||||||
metrics.tasksCompletedToday.toString(),
|
|
||||||
),
|
|
||||||
_MetricCard(
|
|
||||||
title: 'Open tasks',
|
|
||||||
valueBuilder: (metrics) => metrics.openTasks.toString(),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
final staffColumn = Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_sectionTitle(context, 'IT Staff Pulse'),
|
|
||||||
const _StaffTable(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isWide) {
|
|
||||||
return Center(
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: constraints.maxWidth * 0.6,
|
|
||||||
),
|
|
||||||
child: metricsColumn,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 20),
|
|
||||||
ConstrainedBox(
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
maxWidth: constraints.maxWidth * 0.35,
|
|
||||||
),
|
|
||||||
child: staffColumn,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
child: Column(
|
child: content,
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
metricsColumn,
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
staffColumn,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -415,21 +375,42 @@ class DashboardScreen extends StatelessWidget {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final width = constraints.maxWidth;
|
||||||
final columns = width >= 900
|
if (width < 520) {
|
||||||
? 3
|
return Column(
|
||||||
: width >= 620
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
? 2
|
children: [
|
||||||
: 1;
|
for (var i = 0; i < cards.length; i++) ...[
|
||||||
|
cards[i],
|
||||||
|
if (i < cards.length - 1) const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
final spacing = 12.0;
|
final spacing = 12.0;
|
||||||
final cardWidth = (width - (columns - 1) * spacing) / columns;
|
final minCardWidth = 220.0;
|
||||||
return Wrap(
|
final totalWidth =
|
||||||
alignment: WrapAlignment.center,
|
cards.length * minCardWidth + spacing * (cards.length - 1);
|
||||||
runAlignment: WrapAlignment.center,
|
final fits = totalWidth <= width;
|
||||||
spacing: spacing,
|
final cardWidth = fits
|
||||||
runSpacing: spacing,
|
? (width - spacing * (cards.length - 1)) / cards.length
|
||||||
children: cards
|
: minCardWidth;
|
||||||
.map((card) => SizedBox(width: cardWidth, child: card))
|
|
||||||
.toList(),
|
final row = Row(
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < cards.length; i++) ...[
|
||||||
|
SizedBox(width: cardWidth, child: cards[i]),
|
||||||
|
if (i < cards.length - 1) const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fits) {
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: SizedBox(width: totalWidth, child: row),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -476,11 +457,12 @@ class _MetricCard extends ConsumerWidget {
|
||||||
error: (error, _) => 'Error',
|
error: (error, _) => 'Error',
|
||||||
);
|
);
|
||||||
|
|
||||||
return Container(
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -493,7 +475,7 @@ class _MetricCard extends ConsumerWidget {
|
||||||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Text(
|
MonoText(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(
|
style: Theme.of(
|
||||||
context,
|
context,
|
||||||
|
|
@ -587,7 +569,13 @@ class _StaffRow extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
|
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
|
||||||
Expanded(flex: 2, child: Text(row.status, style: valueStyle)),
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: StatusPill(label: row.status),
|
||||||
|
),
|
||||||
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ 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/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
|
||||||
class NotificationsScreen extends ConsumerWidget {
|
class NotificationsScreen extends ConsumerWidget {
|
||||||
|
|
@ -73,10 +74,21 @@ class NotificationsScreen extends ConsumerWidget {
|
||||||
final title = _notificationTitle(item.type, actorName);
|
final title = _notificationTitle(item.type, actorName);
|
||||||
final icon = _notificationIcon(item.type);
|
final icon = _notificationIcon(item.type);
|
||||||
|
|
||||||
return ListTile(
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
leading: Icon(icon),
|
leading: Icon(icon),
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
subtitle: Text(subtitle),
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(subtitle),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
if (item.ticketId != null)
|
||||||
|
MonoText('Ticket ${item.ticketId}')
|
||||||
|
else if (item.taskId != null)
|
||||||
|
MonoText('Task ${item.taskId}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: item.isUnread
|
trailing: item.isUnread
|
||||||
? const Icon(
|
? const Icon(
|
||||||
Icons.circle,
|
Icons.circle,
|
||||||
|
|
@ -107,6 +119,7 @@ class NotificationsScreen extends ConsumerWidget {
|
||||||
context.go('/tickets/$ticketId');
|
context.go('/tickets/$ticketId');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,10 @@ 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 '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../widgets/app_breakpoints.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/status_pill.dart';
|
||||||
import '../../widgets/task_assignment_section.dart';
|
import '../../widgets/task_assignment_section.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
|
|
||||||
|
|
@ -102,11 +105,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
);
|
);
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: Column(
|
child: LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
Padding(
|
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
|
||||||
child: Column(
|
final detailsContent = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
|
|
@ -114,9 +117,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
child: Text(
|
child: Text(
|
||||||
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
|
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.w700,
|
context,
|
||||||
),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|
@ -128,14 +131,15 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, task, canUpdateStatus),
|
_buildStatusChip(context, task, canUpdateStatus),
|
||||||
Text('Office: $officeName'),
|
_MetaBadge(label: 'Office', value: officeName),
|
||||||
|
_MetaBadge(label: 'Task ID', value: task.id, isMono: true),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (description.isNotEmpty) ...[
|
if (description.isNotEmpty) ...[
|
||||||
|
|
@ -147,9 +151,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TaskAssignmentSection(taskId: task.id, canAssign: showAssign),
|
TaskAssignmentSection(taskId: task.id, canAssign: showAssign),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final detailsCard = Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: SingleChildScrollView(child: detailsContent),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const Divider(height: 1),
|
|
||||||
|
final messagesCard = Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: messagesAsync.when(
|
child: messagesAsync.when(
|
||||||
data: (messages) => _buildMessages(
|
data: (messages) => _buildMessages(
|
||||||
|
|
@ -157,9 +172,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
messages,
|
messages,
|
||||||
profilesAsync.valueOrNull ?? [],
|
profilesAsync.valueOrNull ?? [],
|
||||||
),
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () =>
|
||||||
error: (error, _) =>
|
const Center(child: CircularProgressIndicator()),
|
||||||
Center(child: Text('Failed to load messages: $error')),
|
error: (error, _) => Center(
|
||||||
|
child: Text('Failed to load messages: $error'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
|
@ -187,13 +204,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_typingLabel(typingState.userIds, profilesAsync),
|
_typingLabel(
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
typingState.userIds,
|
||||||
|
profilesAsync,
|
||||||
|
),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TypingDots(
|
TypingDots(
|
||||||
size: 8,
|
size: 8,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -259,6 +283,28 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: detailsCard),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(flex: 3, child: messagesCard),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
detailsCard,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(child: messagesCard),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -736,13 +782,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
Task task,
|
Task task,
|
||||||
bool canUpdateStatus,
|
bool canUpdateStatus,
|
||||||
) {
|
) {
|
||||||
final chip = Chip(
|
final chip = StatusPill(
|
||||||
label: Text(task.status.toUpperCase()),
|
label: task.status.toUpperCase(),
|
||||||
backgroundColor: _statusColor(context, task.status),
|
isEmphasized: task.status != 'queued',
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: _statusTextColor(context, task.status),
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!canUpdateStatus) {
|
if (!canUpdateStatus) {
|
||||||
|
|
@ -777,24 +819,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusColor(BuildContext context, String status) {
|
|
||||||
return switch (status) {
|
|
||||||
'queued' => Colors.blueGrey.shade200,
|
|
||||||
'in_progress' => Colors.blue.shade300,
|
|
||||||
'completed' => Colors.green.shade300,
|
|
||||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _statusTextColor(BuildContext context, String status) {
|
|
||||||
return switch (status) {
|
|
||||||
'queued' => Colors.blueGrey.shade900,
|
|
||||||
'in_progress' => Colors.blue.shade900,
|
|
||||||
'completed' => Colors.green.shade900,
|
|
||||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _canUpdateStatus(
|
bool _canUpdateStatus(
|
||||||
Profile? profile,
|
Profile? profile,
|
||||||
List<TaskAssignment> assignments,
|
List<TaskAssignment> assignments,
|
||||||
|
|
@ -817,6 +841,40 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _MetaBadge extends StatelessWidget {
|
||||||
|
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool? isMono;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final border = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
final background = Theme.of(context).colorScheme.surfaceContainerLow;
|
||||||
|
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(label, style: textStyle),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
if (isMono == true)
|
||||||
|
MonoText(value, style: textStyle)
|
||||||
|
else
|
||||||
|
Text(value, style: textStyle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension _FirstOrNull<T> on Iterable<T> {
|
extension _FirstOrNull<T> on Iterable<T> {
|
||||||
T? get firstOrNull => isEmpty ? null : first;
|
T? get firstOrNull => isEmpty ? null : first;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,56 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../models/notification_item.dart';
|
import '../../models/notification_item.dart';
|
||||||
|
import '../../models/office.dart';
|
||||||
|
import '../../models/profile.dart';
|
||||||
import '../../models/task.dart';
|
import '../../models/task.dart';
|
||||||
|
import '../../models/ticket.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
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 '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
|
|
||||||
class TasksListScreen extends ConsumerWidget {
|
class TasksListScreen extends ConsumerStatefulWidget {
|
||||||
const TasksListScreen({super.key});
|
const TasksListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
|
final TextEditingController _subjectController = TextEditingController();
|
||||||
|
String? _selectedOfficeId;
|
||||||
|
String? _selectedStatus;
|
||||||
|
String? _selectedAssigneeId;
|
||||||
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subjectController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasTaskFilters {
|
||||||
|
return _subjectController.text.trim().isNotEmpty ||
|
||||||
|
_selectedOfficeId != null ||
|
||||||
|
_selectedStatus != null ||
|
||||||
|
_selectedAssigneeId != null ||
|
||||||
|
_selectedDateRange != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final tasksAsync = ref.watch(tasksProvider);
|
final tasksAsync = ref.watch(tasksProvider);
|
||||||
final ticketsAsync = ref.watch(ticketsProvider);
|
final ticketsAsync = ref.watch(ticketsProvider);
|
||||||
final officesAsync = ref.watch(officesProvider);
|
final officesAsync = ref.watch(officesProvider);
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
final profileAsync = ref.watch(currentProfileProvider);
|
||||||
final notificationsAsync = ref.watch(notificationsProvider);
|
final notificationsAsync = ref.watch(notificationsProvider);
|
||||||
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
|
||||||
final canCreate = profileAsync.maybeWhen(
|
final canCreate = profileAsync.maybeWhen(
|
||||||
data: (profile) =>
|
data: (profile) =>
|
||||||
|
|
@ -32,75 +63,238 @@ class TasksListScreen extends ConsumerWidget {
|
||||||
orElse: () => false,
|
orElse: () => false,
|
||||||
);
|
);
|
||||||
|
|
||||||
final ticketById = {
|
final ticketById = <String, Ticket>{
|
||||||
for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
|
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
||||||
|
ticket.id: ticket,
|
||||||
};
|
};
|
||||||
final officeById = {
|
final officeById = <String, Office>{
|
||||||
for (final office in officesAsync.valueOrNull ?? []) office.id: office,
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
||||||
|
office.id: office,
|
||||||
|
};
|
||||||
|
final profileById = <String, Profile>{
|
||||||
|
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
|
||||||
|
profile.id: profile,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Scaffold(
|
return Stack(
|
||||||
body: ResponsiveBody(
|
children: [
|
||||||
|
ResponsiveBody(
|
||||||
|
maxWidth: double.infinity,
|
||||||
child: tasksAsync.when(
|
child: tasksAsync.when(
|
||||||
data: (tasks) {
|
data: (tasks) {
|
||||||
if (tasks.isEmpty) {
|
if (tasks.isEmpty) {
|
||||||
return const Center(child: Text('No tasks yet.'));
|
return const Center(child: Text('No tasks yet.'));
|
||||||
}
|
}
|
||||||
return Column(
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final officeOptions = <DropdownMenuItem<String?>>[
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('All offices'),
|
||||||
|
),
|
||||||
|
...offices.map(
|
||||||
|
(office) => DropdownMenuItem<String?>(
|
||||||
|
value: office.id,
|
||||||
|
child: Text(office.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
||||||
|
final statusOptions = _taskStatusOptions(tasks);
|
||||||
|
final filteredTasks = _applyTaskFilters(
|
||||||
|
tasks,
|
||||||
|
ticketById: ticketById,
|
||||||
|
subjectQuery: _subjectController.text,
|
||||||
|
officeId: _selectedOfficeId,
|
||||||
|
status: _selectedStatus,
|
||||||
|
assigneeId: _selectedAssigneeId,
|
||||||
|
dateRange: _selectedDateRange,
|
||||||
|
);
|
||||||
|
final summaryDashboard = _StatusSummaryRow(
|
||||||
|
counts: _taskStatusCounts(filteredTasks),
|
||||||
|
);
|
||||||
|
final filterHeader = Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
SizedBox(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
width: 220,
|
||||||
child: Align(
|
child: TextField(
|
||||||
alignment: Alignment.center,
|
controller: _subjectController,
|
||||||
child: Text(
|
onChanged: (_) => setState(() {}),
|
||||||
'Tasks',
|
decoration: const InputDecoration(
|
||||||
textAlign: TextAlign.center,
|
labelText: 'Subject',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
prefixIcon: Icon(Icons.search),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
key: ValueKey(_selectedOfficeId),
|
||||||
|
initialValue: _selectedOfficeId,
|
||||||
|
items: officeOptions,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _selectedOfficeId = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Office'),
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: ListView.separated(
|
SizedBox(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
width: 220,
|
||||||
itemCount: tasks.length,
|
child: DropdownButtonFormField<String?>(
|
||||||
separatorBuilder: (context, index) =>
|
isExpanded: true,
|
||||||
const SizedBox(height: 12),
|
key: ValueKey(_selectedAssigneeId),
|
||||||
itemBuilder: (context, index) {
|
initialValue: _selectedAssigneeId,
|
||||||
final task = tasks[index];
|
items: staffOptions,
|
||||||
final ticketId = task.ticketId;
|
onChanged: (value) =>
|
||||||
final ticket = ticketId == null
|
setState(() => _selectedAssigneeId = value),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Assigned staff',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 180,
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
key: ValueKey(_selectedStatus),
|
||||||
|
initialValue: _selectedStatus,
|
||||||
|
items: statusOptions,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _selectedStatus = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Status'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final next = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
currentDate: DateTime.now(),
|
||||||
|
initialDateRange: _selectedDateRange,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _selectedDateRange = next);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.date_range),
|
||||||
|
label: Text(
|
||||||
|
_selectedDateRange == null
|
||||||
|
? 'Date range'
|
||||||
|
: _formatDateRange(_selectedDateRange!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_hasTaskFilters)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_subjectController.clear();
|
||||||
|
_selectedOfficeId = null;
|
||||||
|
_selectedStatus = null;
|
||||||
|
_selectedAssigneeId = null;
|
||||||
|
_selectedDateRange = null;
|
||||||
|
}),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
label: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final listBody = TasQAdaptiveList<Task>(
|
||||||
|
items: filteredTasks,
|
||||||
|
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
||||||
|
summaryDashboard: summaryDashboard,
|
||||||
|
filterHeader: filterHeader,
|
||||||
|
columns: [
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Task ID',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, task) => Text(task.id),
|
||||||
|
),
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Subject',
|
||||||
|
cellBuilder: (context, task) {
|
||||||
|
final ticket = task.ticketId == null
|
||||||
? null
|
? null
|
||||||
: ticketById[ticketId];
|
: ticketById[task.ticketId];
|
||||||
|
return Text(
|
||||||
|
task.title.isNotEmpty
|
||||||
|
? task.title
|
||||||
|
: (ticket?.subject ?? 'Task ${task.id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Office',
|
||||||
|
cellBuilder: (context, task) {
|
||||||
|
final ticket = task.ticketId == null
|
||||||
|
? null
|
||||||
|
: ticketById[task.ticketId];
|
||||||
|
final officeId = ticket?.officeId ?? task.officeId;
|
||||||
|
return Text(
|
||||||
|
officeId == null
|
||||||
|
? 'Unassigned office'
|
||||||
|
: (officeById[officeId]?.name ?? officeId),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Assigned Agent',
|
||||||
|
cellBuilder: (context, task) =>
|
||||||
|
Text(_assignedAgent(profileById, task.creatorId)),
|
||||||
|
),
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Status',
|
||||||
|
cellBuilder: (context, task) =>
|
||||||
|
_StatusBadge(status: task.status),
|
||||||
|
),
|
||||||
|
TasQColumn<Task>(
|
||||||
|
header: 'Timestamp',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, task) =>
|
||||||
|
Text(_formatTimestamp(task.createdAt)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
mobileTileBuilder: (context, task, actions) {
|
||||||
|
final ticketId = task.ticketId;
|
||||||
|
final ticket = ticketId == null ? null : ticketById[ticketId];
|
||||||
final officeId = ticket?.officeId ?? task.officeId;
|
final officeId = ticket?.officeId ?? task.officeId;
|
||||||
final officeName = officeId == null
|
final officeName = officeId == null
|
||||||
? 'Unassigned office'
|
? 'Unassigned office'
|
||||||
: (officeById[officeId]?.name ?? officeId);
|
: (officeById[officeId]?.name ?? officeId);
|
||||||
|
final assigned = _assignedAgent(profileById, task.creatorId);
|
||||||
final subtitle = _buildSubtitle(officeName, task.status);
|
final subtitle = _buildSubtitle(officeName, task.status);
|
||||||
final hasMention = _hasTaskMention(
|
final hasMention = _hasTaskMention(notificationsAsync, task);
|
||||||
notificationsAsync,
|
|
||||||
task,
|
|
||||||
);
|
|
||||||
final typingChannelId = task.id;
|
|
||||||
final typingState = ref.watch(
|
final typingState = ref.watch(
|
||||||
typingIndicatorProvider(typingChannelId),
|
typingIndicatorProvider(task.id),
|
||||||
);
|
);
|
||||||
final showTyping = typingState.userIds.isNotEmpty;
|
final showTyping = typingState.userIds.isNotEmpty;
|
||||||
|
|
||||||
return ListTile(
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
leading: _buildQueueBadge(context, task),
|
leading: _buildQueueBadge(context, task),
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
title: Text(
|
title: Text(
|
||||||
task.title.isNotEmpty
|
task.title.isNotEmpty
|
||||||
? task.title
|
? task.title
|
||||||
: (ticket?.subject ?? 'Task ${task.id}'),
|
: (ticket?.subject ?? 'Task ${task.id}'),
|
||||||
),
|
),
|
||||||
subtitle: Text(subtitle),
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(subtitle),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('Assigned: $assigned'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MonoText('ID ${task.id}'),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(_formatTimestamp(task.createdAt)),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, task.status),
|
_StatusBadge(status: task.status),
|
||||||
if (showTyping) ...[
|
if (showTyping) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
TypingDots(
|
TypingDots(
|
||||||
|
|
@ -120,10 +314,29 @@ class TasksListScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () => context.go('/tasks/${task.id}'),
|
onTap: () => context.go('/tasks/${task.id}'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'Tasks',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: listBody),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -132,13 +345,19 @@ class TasksListScreen extends ConsumerWidget {
|
||||||
Center(child: Text('Failed to load tasks: $error')),
|
Center(child: Text('Failed to load tasks: $error')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: canCreate
|
if (canCreate)
|
||||||
? FloatingActionButton.extended(
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
onPressed: () => _showCreateTaskDialog(context, ref),
|
onPressed: () => _showCreateTaskDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Task'),
|
label: const Text('New Task'),
|
||||||
)
|
),
|
||||||
: null,
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,33 +506,265 @@ class TasksListScreen extends ConsumerWidget {
|
||||||
final statusLabel = status.toUpperCase();
|
final statusLabel = status.toUpperCase();
|
||||||
return '$officeName · $statusLabel';
|
return '$officeName · $statusLabel';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildStatusChip(BuildContext context, String status) {
|
List<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
|
||||||
return Chip(
|
final items = profiles ?? const <Profile>[];
|
||||||
label: Text(status.toUpperCase()),
|
final sorted = [...items]
|
||||||
backgroundColor: _statusColor(context, status),
|
..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b)));
|
||||||
labelStyle: TextStyle(
|
return [
|
||||||
color: _statusTextColor(context, status),
|
const DropdownMenuItem<String?>(value: null, child: Text('All staff')),
|
||||||
|
...sorted.map(
|
||||||
|
(profile) => DropdownMenuItem<String?>(
|
||||||
|
value: profile.id,
|
||||||
|
child: Text(_profileLabel(profile)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _profileLabel(Profile profile) {
|
||||||
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<DropdownMenuItem<String?>> _taskStatusOptions(List<Task> tasks) {
|
||||||
|
final statuses = tasks.map((task) => task.status).toSet().toList()..sort();
|
||||||
|
return [
|
||||||
|
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
|
||||||
|
...statuses.map(
|
||||||
|
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Task> _applyTaskFilters(
|
||||||
|
List<Task> tasks, {
|
||||||
|
required Map<String, Ticket> ticketById,
|
||||||
|
required String subjectQuery,
|
||||||
|
required String? officeId,
|
||||||
|
required String? status,
|
||||||
|
required String? assigneeId,
|
||||||
|
required DateTimeRange? dateRange,
|
||||||
|
}) {
|
||||||
|
final query = subjectQuery.trim().toLowerCase();
|
||||||
|
return tasks.where((task) {
|
||||||
|
final ticket = task.ticketId == null ? null : ticketById[task.ticketId];
|
||||||
|
final subject = task.title.isNotEmpty
|
||||||
|
? task.title
|
||||||
|
: (ticket?.subject ?? 'Task ${task.id}');
|
||||||
|
if (query.isNotEmpty &&
|
||||||
|
!subject.toLowerCase().contains(query) &&
|
||||||
|
!task.id.toLowerCase().contains(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final resolvedOfficeId = ticket?.officeId ?? task.officeId;
|
||||||
|
if (officeId != null && resolvedOfficeId != officeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status != null && task.status != status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (assigneeId != null && task.creatorId != assigneeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (dateRange != null) {
|
||||||
|
final start = DateTime(
|
||||||
|
dateRange.start.year,
|
||||||
|
dateRange.start.month,
|
||||||
|
dateRange.start.day,
|
||||||
|
);
|
||||||
|
final end = DateTime(
|
||||||
|
dateRange.end.year,
|
||||||
|
dateRange.end.month,
|
||||||
|
dateRange.end.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
);
|
||||||
|
if (task.createdAt.isBefore(start) || task.createdAt.isAfter(end)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, int> _taskStatusCounts(List<Task> tasks) {
|
||||||
|
final counts = <String, int>{};
|
||||||
|
for (final task in tasks) {
|
||||||
|
counts.update(task.status, (value) => value + 1, ifAbsent: () => 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateRange(DateTimeRange range) {
|
||||||
|
return '${_formatDate(range.start)} - ${_formatDate(range.end)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime value) {
|
||||||
|
final year = value.year.toString().padLeft(4, '0');
|
||||||
|
final month = value.month.toString().padLeft(2, '0');
|
||||||
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusSummaryRow extends StatelessWidget {
|
||||||
|
const _StatusSummaryRow({required this.counts});
|
||||||
|
|
||||||
|
final Map<String, int> counts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (counts.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final entries = counts.entries.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth;
|
||||||
|
final maxPerRow = maxWidth >= 1000
|
||||||
|
? 4
|
||||||
|
: maxWidth >= 720
|
||||||
|
? 3
|
||||||
|
: maxWidth >= 480
|
||||||
|
? 2
|
||||||
|
: entries.length;
|
||||||
|
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
||||||
|
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
||||||
|
final itemWidth = perRow == 0
|
||||||
|
? maxWidth
|
||||||
|
: (maxWidth - spacing * (perRow - 1)) / perRow;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children: [
|
||||||
|
for (final entry in entries)
|
||||||
|
SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
|
child: _StatusSummaryCard(
|
||||||
|
status: entry.key,
|
||||||
|
count: entry.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusSummaryCard extends StatelessWidget {
|
||||||
|
const _StatusSummaryCard({required this.status, required this.count});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = switch (status) {
|
||||||
|
'critical' => scheme.errorContainer,
|
||||||
|
'queued' => scheme.surfaceContainerHighest,
|
||||||
|
'in_progress' => scheme.secondaryContainer,
|
||||||
|
'completed' => scheme.primaryContainer,
|
||||||
|
_ => scheme.surfaceContainerHigh,
|
||||||
|
};
|
||||||
|
final foreground = switch (status) {
|
||||||
|
'critical' => scheme.onErrorContainer,
|
||||||
|
'queued' => scheme.onSurfaceVariant,
|
||||||
|
'in_progress' => scheme.onSecondaryContainer,
|
||||||
|
'completed' => scheme.onPrimaryContainer,
|
||||||
|
_ => scheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: background,
|
||||||
|
elevation: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusColor(BuildContext context, String status) {
|
|
||||||
return switch (status) {
|
|
||||||
'queued' => Colors.blueGrey.shade200,
|
|
||||||
'in_progress' => Colors.blue.shade300,
|
|
||||||
'completed' => Colors.green.shade300,
|
|
||||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusTextColor(BuildContext context, String status) {
|
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||||
return switch (status) {
|
if (userId == null || userId.isEmpty) {
|
||||||
'queued' => Colors.blueGrey.shade900,
|
return 'Unassigned';
|
||||||
'in_progress' => Colors.blue.shade900,
|
}
|
||||||
'completed' => Colors.green.shade900,
|
final profile = profileById[userId];
|
||||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
if (profile == null) {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime value) {
|
||||||
|
final year = value.year.toString().padLeft(4, '0');
|
||||||
|
final month = value.month.toString().padLeft(2, '0');
|
||||||
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
|
final hour = value.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = value.minute.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day $hour:$minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
const _StatusBadge({required this.status});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = switch (status) {
|
||||||
|
'critical' => scheme.errorContainer,
|
||||||
|
'queued' => scheme.surfaceContainerHighest,
|
||||||
|
'in_progress' => scheme.secondaryContainer,
|
||||||
|
'completed' => scheme.primaryContainer,
|
||||||
|
_ => scheme.surfaceContainerHighest,
|
||||||
};
|
};
|
||||||
|
final foreground = switch (status) {
|
||||||
|
'critical' => scheme.onErrorContainer,
|
||||||
|
'queued' => scheme.onSurfaceVariant,
|
||||||
|
'in_progress' => scheme.onSecondaryContainer,
|
||||||
|
'completed' => scheme.onPrimaryContainer,
|
||||||
|
_ => scheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Badge(
|
||||||
|
backgroundColor: background,
|
||||||
|
label: Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,10 @@ 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 '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../widgets/app_breakpoints.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/status_pill.dart';
|
||||||
import '../../widgets/task_assignment_section.dart';
|
import '../../widgets/task_assignment_section.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
|
|
||||||
|
|
@ -80,12 +83,14 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
: ticket?.respondedAt;
|
: ticket?.respondedAt;
|
||||||
|
|
||||||
return ResponsiveBody(
|
return ResponsiveBody(
|
||||||
child: Column(
|
child: LayoutBuilder(
|
||||||
children: [
|
builder: (context, constraints) {
|
||||||
if (ticket != null)
|
if (ticket == null) {
|
||||||
Padding(
|
return const Center(child: Text('Ticket not found.'));
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
}
|
||||||
child: Column(
|
|
||||||
|
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
|
||||||
|
final detailsContent = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
Align(
|
||||||
|
|
@ -93,9 +98,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
child: Text(
|
child: Text(
|
||||||
ticket.subject,
|
ticket.subject,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
style: Theme.of(
|
||||||
fontWeight: FontWeight.w700,
|
context,
|
||||||
),
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
|
@ -107,14 +112,22 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
crossAxisAlignment: WrapCrossAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, ref, ticket, canPromote),
|
_buildStatusChip(context, ref, ticket, canPromote),
|
||||||
Text('Office: ${_officeLabel(officesAsync, ticket)}'),
|
_MetaBadge(
|
||||||
|
label: 'Office',
|
||||||
|
value: _officeLabel(officesAsync, ticket),
|
||||||
|
),
|
||||||
|
_MetaBadge(
|
||||||
|
label: 'Ticket ID',
|
||||||
|
value: ticket.id,
|
||||||
|
isMono: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -135,17 +148,27 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Open task',
|
tooltip: 'Open task',
|
||||||
onPressed: () =>
|
onPressed: () => context.go('/tasks/${taskForTicket.id}'),
|
||||||
context.go('/tasks/${taskForTicket.id}'),
|
|
||||||
icon: const Icon(Icons.open_in_new),
|
icon: const Icon(Icons.open_in_new),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final detailsCard = Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: SingleChildScrollView(child: detailsContent),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
const Divider(height: 1),
|
|
||||||
|
final messagesCard = Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: messagesAsync.when(
|
child: messagesAsync.when(
|
||||||
data: (messages) {
|
data: (messages) {
|
||||||
|
|
@ -173,9 +196,13 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
message.senderId!;
|
message.senderId!;
|
||||||
final bubbleColor = isMe
|
final bubbleColor = isMe
|
||||||
? Theme.of(context).colorScheme.primaryContainer
|
? Theme.of(context).colorScheme.primaryContainer
|
||||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest;
|
||||||
final textColor = isMe
|
final textColor = isMe
|
||||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onPrimaryContainer
|
||||||
: Theme.of(context).colorScheme.onSurface;
|
: Theme.of(context).colorScheme.onSurface;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
|
|
@ -192,20 +219,28 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
senderName,
|
senderName,
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(
|
||||||
|
maxWidth: 520,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bubbleColor,
|
color: bubbleColor,
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(16),
|
topLeft: const Radius.circular(16),
|
||||||
topRight: const Radius.circular(16),
|
topRight: const Radius.circular(16),
|
||||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
bottomLeft: Radius.circular(
|
||||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
isMe ? 16 : 4,
|
||||||
|
),
|
||||||
|
bottomRight: Radius.circular(
|
||||||
|
isMe ? 4 : 16,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _buildMentionText(
|
child: _buildMentionText(
|
||||||
|
|
@ -220,9 +255,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () =>
|
||||||
error: (error, _) =>
|
const Center(child: CircularProgressIndicator()),
|
||||||
Center(child: Text('Failed to load messages: $error')),
|
error: (error, _) => Center(
|
||||||
|
child: Text('Failed to load messages: $error'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SafeArea(
|
||||||
|
|
@ -250,13 +287,20 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
_typingLabel(typingState.userIds, profilesAsync),
|
_typingLabel(
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
typingState.userIds,
|
||||||
|
profilesAsync,
|
||||||
|
),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TypingDots(
|
TypingDots(
|
||||||
size: 8,
|
size: 8,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -288,7 +332,12 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
onChanged: canSendMessages
|
onChanged: canSendMessages
|
||||||
? (_) => _handleComposerChanged(
|
? (_) => _handleComposerChanged(
|
||||||
profilesAsync.valueOrNull ?? [],
|
profilesAsync.valueOrNull ?? [],
|
||||||
Supabase.instance.client.auth.currentUser?.id,
|
Supabase
|
||||||
|
.instance
|
||||||
|
.client
|
||||||
|
.auth
|
||||||
|
.currentUser
|
||||||
|
?.id,
|
||||||
canSendMessages,
|
canSendMessages,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -296,7 +345,12 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
? (_) => _handleSendMessage(
|
? (_) => _handleSendMessage(
|
||||||
ref,
|
ref,
|
||||||
profilesAsync.valueOrNull ?? [],
|
profilesAsync.valueOrNull ?? [],
|
||||||
Supabase.instance.client.auth.currentUser?.id,
|
Supabase
|
||||||
|
.instance
|
||||||
|
.client
|
||||||
|
.auth
|
||||||
|
.currentUser
|
||||||
|
?.id,
|
||||||
canSendMessages,
|
canSendMessages,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -309,7 +363,12 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
? () => _handleSendMessage(
|
? () => _handleSendMessage(
|
||||||
ref,
|
ref,
|
||||||
profilesAsync.valueOrNull ?? [],
|
profilesAsync.valueOrNull ?? [],
|
||||||
Supabase.instance.client.auth.currentUser?.id,
|
Supabase
|
||||||
|
.instance
|
||||||
|
.client
|
||||||
|
.auth
|
||||||
|
.currentUser
|
||||||
|
?.id,
|
||||||
canSendMessages,
|
canSendMessages,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -323,6 +382,28 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(flex: 2, child: detailsCard),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(flex: 3, child: messagesCard),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
detailsCard,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Expanded(child: messagesCard),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -778,13 +859,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
bool canPromote,
|
bool canPromote,
|
||||||
) {
|
) {
|
||||||
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
|
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
|
||||||
final chip = Chip(
|
final chip = StatusPill(
|
||||||
label: Text(_statusLabel(ticket.status)),
|
label: _statusLabel(ticket.status),
|
||||||
backgroundColor: _statusColor(context, ticket.status),
|
isEmphasized: ticket.status != 'pending',
|
||||||
labelStyle: TextStyle(
|
|
||||||
color: _statusTextColor(context, ticket.status),
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isLocked) {
|
if (isLocked) {
|
||||||
|
|
@ -834,23 +911,39 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
bool _canAssignStaff(String role) {
|
bool _canAssignStaff(String role) {
|
||||||
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
|
return role == 'admin' || role == 'dispatcher' || role == 'it_staff';
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusColor(BuildContext context, String status) {
|
|
||||||
return switch (status) {
|
|
||||||
'pending' => Colors.amber.shade300,
|
|
||||||
'promoted' => Colors.blue.shade300,
|
|
||||||
'closed' => Colors.green.shade300,
|
|
||||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusTextColor(BuildContext context, String status) {
|
class _MetaBadge extends StatelessWidget {
|
||||||
return switch (status) {
|
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||||
'pending' => Colors.brown.shade900,
|
|
||||||
'promoted' => Colors.blue.shade900,
|
final String label;
|
||||||
'closed' => Colors.green.shade900,
|
final String value;
|
||||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
final bool? isMono;
|
||||||
};
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final border = Theme.of(context).colorScheme.outlineVariant;
|
||||||
|
final background = Theme.of(context).colorScheme.surfaceContainerLow;
|
||||||
|
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(label, style: textStyle),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
if (isMono == true)
|
||||||
|
MonoText(value, style: textStyle)
|
||||||
|
else
|
||||||
|
Text(value, style: textStyle),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,72 +4,236 @@ import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../models/office.dart';
|
import '../../models/office.dart';
|
||||||
import '../../models/notification_item.dart';
|
import '../../models/notification_item.dart';
|
||||||
|
import '../../models/profile.dart';
|
||||||
|
import '../../models/ticket.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
|
import '../../providers/profile_provider.dart';
|
||||||
import '../../providers/tickets_provider.dart';
|
import '../../providers/tickets_provider.dart';
|
||||||
import '../../providers/typing_provider.dart';
|
import '../../providers/typing_provider.dart';
|
||||||
|
import '../../widgets/mono_text.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
|
import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
|
|
||||||
class TicketsListScreen extends ConsumerWidget {
|
class TicketsListScreen extends ConsumerStatefulWidget {
|
||||||
const TicketsListScreen({super.key});
|
const TicketsListScreen({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<TicketsListScreen> createState() => _TicketsListScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
|
final TextEditingController _subjectController = TextEditingController();
|
||||||
|
String? _selectedOfficeId;
|
||||||
|
String? _selectedStatus;
|
||||||
|
DateTimeRange? _selectedDateRange;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_subjectController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _hasTicketFilters {
|
||||||
|
return _subjectController.text.trim().isNotEmpty ||
|
||||||
|
_selectedOfficeId != null ||
|
||||||
|
_selectedStatus != null ||
|
||||||
|
_selectedDateRange != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final ticketsAsync = ref.watch(ticketsProvider);
|
final ticketsAsync = ref.watch(ticketsProvider);
|
||||||
final officesAsync = ref.watch(officesProvider);
|
final officesAsync = ref.watch(officesProvider);
|
||||||
final notificationsAsync = ref.watch(notificationsProvider);
|
final notificationsAsync = ref.watch(notificationsProvider);
|
||||||
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Stack(
|
||||||
body: ResponsiveBody(
|
children: [
|
||||||
|
ResponsiveBody(
|
||||||
|
maxWidth: double.infinity,
|
||||||
child: ticketsAsync.when(
|
child: ticketsAsync.when(
|
||||||
data: (tickets) {
|
data: (tickets) {
|
||||||
if (tickets.isEmpty) {
|
if (tickets.isEmpty) {
|
||||||
return const Center(child: Text('No tickets yet.'));
|
return const Center(child: Text('No tickets yet.'));
|
||||||
}
|
}
|
||||||
final officeById = {
|
final officeById = <String, Office>{
|
||||||
for (final office in officesAsync.valueOrNull ?? [])
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
||||||
office.id: office,
|
office.id: office,
|
||||||
};
|
};
|
||||||
|
final profileById = <String, Profile>{
|
||||||
|
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
|
||||||
|
profile.id: profile,
|
||||||
|
};
|
||||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||||
return Column(
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
final officeOptions = <DropdownMenuItem<String?>>[
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Text('All offices'),
|
||||||
|
),
|
||||||
|
...offices.map(
|
||||||
|
(office) => DropdownMenuItem<String?>(
|
||||||
|
value: office.id,
|
||||||
|
child: Text(office.name),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
final statusOptions = _ticketStatusOptions(tickets);
|
||||||
|
final filteredTickets = _applyTicketFilters(
|
||||||
|
tickets,
|
||||||
|
subjectQuery: _subjectController.text,
|
||||||
|
officeId: _selectedOfficeId,
|
||||||
|
status: _selectedStatus,
|
||||||
|
dateRange: _selectedDateRange,
|
||||||
|
);
|
||||||
|
final summaryDashboard = _StatusSummaryRow(
|
||||||
|
counts: _statusCounts(filteredTickets),
|
||||||
|
);
|
||||||
|
final filterHeader = Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
SizedBox(
|
||||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
width: 220,
|
||||||
child: Align(
|
child: TextField(
|
||||||
alignment: Alignment.center,
|
controller: _subjectController,
|
||||||
child: Text(
|
onChanged: (_) => setState(() {}),
|
||||||
'Tickets',
|
decoration: const InputDecoration(
|
||||||
textAlign: TextAlign.center,
|
labelText: 'Subject',
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
prefixIcon: Icon(Icons.search),
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: DropdownButtonFormField<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
key: ValueKey(_selectedOfficeId),
|
||||||
|
initialValue: _selectedOfficeId,
|
||||||
|
items: officeOptions,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _selectedOfficeId = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Office'),
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: ListView.separated(
|
SizedBox(
|
||||||
padding: const EdgeInsets.only(bottom: 24),
|
width: 180,
|
||||||
itemCount: tickets.length,
|
child: DropdownButtonFormField<String?>(
|
||||||
separatorBuilder: (context, index) =>
|
isExpanded: true,
|
||||||
const SizedBox(height: 12),
|
key: ValueKey(_selectedStatus),
|
||||||
itemBuilder: (context, index) {
|
initialValue: _selectedStatus,
|
||||||
final ticket = tickets[index];
|
items: statusOptions,
|
||||||
|
onChanged: (value) =>
|
||||||
|
setState(() => _selectedStatus = value),
|
||||||
|
decoration: const InputDecoration(labelText: 'Status'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final next = await showDateRangePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
currentDate: DateTime.now(),
|
||||||
|
initialDateRange: _selectedDateRange,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _selectedDateRange = next);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.date_range),
|
||||||
|
label: Text(
|
||||||
|
_selectedDateRange == null
|
||||||
|
? 'Date range'
|
||||||
|
: _formatDateRange(_selectedDateRange!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_hasTicketFilters)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_subjectController.clear();
|
||||||
|
_selectedOfficeId = null;
|
||||||
|
_selectedStatus = null;
|
||||||
|
_selectedDateRange = null;
|
||||||
|
}),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
label: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final listBody = TasQAdaptiveList<Ticket>(
|
||||||
|
items: filteredTickets,
|
||||||
|
onRowTap: (ticket) => context.go('/tickets/${ticket.id}'),
|
||||||
|
summaryDashboard: summaryDashboard,
|
||||||
|
filterHeader: filterHeader,
|
||||||
|
columns: [
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Ticket ID',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, ticket) => Text(ticket.id),
|
||||||
|
),
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Subject',
|
||||||
|
cellBuilder: (context, ticket) => Text(ticket.subject),
|
||||||
|
),
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Office',
|
||||||
|
cellBuilder: (context, ticket) => Text(
|
||||||
|
officeById[ticket.officeId]?.name ?? ticket.officeId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Assigned Agent',
|
||||||
|
cellBuilder: (context, ticket) =>
|
||||||
|
Text(_assignedAgent(profileById, ticket.creatorId)),
|
||||||
|
),
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Status',
|
||||||
|
cellBuilder: (context, ticket) =>
|
||||||
|
_StatusBadge(status: ticket.status),
|
||||||
|
),
|
||||||
|
TasQColumn<Ticket>(
|
||||||
|
header: 'Timestamp',
|
||||||
|
technical: true,
|
||||||
|
cellBuilder: (context, ticket) =>
|
||||||
|
Text(_formatTimestamp(ticket.createdAt)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
mobileTileBuilder: (context, ticket, actions) {
|
||||||
final officeName =
|
final officeName =
|
||||||
officeById[ticket.officeId]?.name ?? ticket.officeId;
|
officeById[ticket.officeId]?.name ?? ticket.officeId;
|
||||||
|
final assigned = _assignedAgent(
|
||||||
|
profileById,
|
||||||
|
ticket.creatorId,
|
||||||
|
);
|
||||||
final hasMention = unreadByTicketId[ticket.id] == true;
|
final hasMention = unreadByTicketId[ticket.id] == true;
|
||||||
final typingState = ref.watch(
|
final typingState = ref.watch(
|
||||||
typingIndicatorProvider(ticket.id),
|
typingIndicatorProvider(ticket.id),
|
||||||
);
|
);
|
||||||
final showTyping = typingState.userIds.isNotEmpty;
|
final showTyping = typingState.userIds.isNotEmpty;
|
||||||
return ListTile(
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
leading: const Icon(Icons.confirmation_number_outlined),
|
leading: const Icon(Icons.confirmation_number_outlined),
|
||||||
|
dense: true,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
title: Text(ticket.subject),
|
title: Text(ticket.subject),
|
||||||
subtitle: Text(officeName),
|
subtitle: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(officeName),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('Assigned: $assigned'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
MonoText('ID ${ticket.id}'),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(_formatTimestamp(ticket.createdAt)),
|
||||||
|
],
|
||||||
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusChip(context, ticket.status),
|
_StatusBadge(status: ticket.status),
|
||||||
if (showTyping) ...[
|
if (showTyping) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
TypingDots(
|
TypingDots(
|
||||||
|
|
@ -89,10 +253,29 @@ class TicketsListScreen extends ConsumerWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
onTap: () => context.go('/tickets/${ticket.id}'),
|
onTap: () => context.go('/tickets/${ticket.id}'),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.max,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'Tickets',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(child: listBody),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -101,11 +284,18 @@ class TicketsListScreen extends ConsumerWidget {
|
||||||
Center(child: Text('Failed to load tickets: $error')),
|
Center(child: Text('Failed to load tickets: $error')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
Positioned(
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: SafeArea(
|
||||||
|
child: FloatingActionButton.extended(
|
||||||
onPressed: () => _showCreateTicketDialog(context, ref),
|
onPressed: () => _showCreateTicketDialog(context, ref),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('New Ticket'),
|
label: const Text('New Ticket'),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,33 +426,237 @@ class TicketsListScreen extends ConsumerWidget {
|
||||||
orElse: () => <String, bool>{},
|
orElse: () => <String, bool>{},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildStatusChip(BuildContext context, String status) {
|
List<DropdownMenuItem<String?>> _ticketStatusOptions(List<Ticket> tickets) {
|
||||||
return Chip(
|
final statuses = tickets.map((ticket) => ticket.status).toSet().toList()
|
||||||
label: Text(status.toUpperCase()),
|
..sort();
|
||||||
backgroundColor: _statusColor(context, status),
|
return [
|
||||||
labelStyle: TextStyle(
|
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
|
||||||
color: _statusTextColor(context, status),
|
...statuses.map(
|
||||||
|
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Ticket> _applyTicketFilters(
|
||||||
|
List<Ticket> tickets, {
|
||||||
|
required String subjectQuery,
|
||||||
|
required String? officeId,
|
||||||
|
required String? status,
|
||||||
|
required DateTimeRange? dateRange,
|
||||||
|
}) {
|
||||||
|
final query = subjectQuery.trim().toLowerCase();
|
||||||
|
return tickets.where((ticket) {
|
||||||
|
if (query.isNotEmpty &&
|
||||||
|
!ticket.subject.toLowerCase().contains(query) &&
|
||||||
|
!ticket.id.toLowerCase().contains(query)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (officeId != null && ticket.officeId != officeId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (status != null && ticket.status != status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (dateRange != null) {
|
||||||
|
final start = DateTime(
|
||||||
|
dateRange.start.year,
|
||||||
|
dateRange.start.month,
|
||||||
|
dateRange.start.day,
|
||||||
|
);
|
||||||
|
final end = DateTime(
|
||||||
|
dateRange.end.year,
|
||||||
|
dateRange.end.month,
|
||||||
|
dateRange.end.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
);
|
||||||
|
if (ticket.createdAt.isBefore(start) || ticket.createdAt.isAfter(end)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, int> _statusCounts(List<Ticket> tickets) {
|
||||||
|
final counts = <String, int>{};
|
||||||
|
for (final ticket in tickets) {
|
||||||
|
counts.update(ticket.status, (value) => value + 1, ifAbsent: () => 1);
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateRange(DateTimeRange range) {
|
||||||
|
return '${_formatDate(range.start)} - ${_formatDate(range.end)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDate(DateTime value) {
|
||||||
|
final year = value.year.toString().padLeft(4, '0');
|
||||||
|
final month = value.month.toString().padLeft(2, '0');
|
||||||
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusSummaryRow extends StatelessWidget {
|
||||||
|
const _StatusSummaryRow({required this.counts});
|
||||||
|
|
||||||
|
final Map<String, int> counts;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (counts.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final entries = counts.entries.toList()
|
||||||
|
..sort((a, b) => a.key.compareTo(b.key));
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth;
|
||||||
|
final maxPerRow = maxWidth >= 1000
|
||||||
|
? 4
|
||||||
|
: maxWidth >= 720
|
||||||
|
? 3
|
||||||
|
: maxWidth >= 480
|
||||||
|
? 2
|
||||||
|
: entries.length;
|
||||||
|
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
||||||
|
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
||||||
|
final itemWidth = perRow == 0
|
||||||
|
? maxWidth
|
||||||
|
: (maxWidth - spacing * (perRow - 1)) / perRow;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
|
children: [
|
||||||
|
for (final entry in entries)
|
||||||
|
SizedBox(
|
||||||
|
width: itemWidth,
|
||||||
|
child: _StatusSummaryCard(
|
||||||
|
status: entry.key,
|
||||||
|
count: entry.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusSummaryCard extends StatelessWidget {
|
||||||
|
const _StatusSummaryCard({required this.status, required this.count});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = switch (status) {
|
||||||
|
'critical' => scheme.errorContainer,
|
||||||
|
'pending' => scheme.tertiaryContainer,
|
||||||
|
'promoted' => scheme.secondaryContainer,
|
||||||
|
'closed' => scheme.primaryContainer,
|
||||||
|
_ => scheme.surfaceContainerHigh,
|
||||||
|
};
|
||||||
|
final foreground = switch (status) {
|
||||||
|
'critical' => scheme.onErrorContainer,
|
||||||
|
'pending' => scheme.onTertiaryContainer,
|
||||||
|
'promoted' => scheme.onSecondaryContainer,
|
||||||
|
'closed' => scheme.onPrimaryContainer,
|
||||||
|
_ => scheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
color: background,
|
||||||
|
elevation: 0,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
count.toString(),
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusColor(BuildContext context, String status) {
|
|
||||||
return switch (status) {
|
|
||||||
'pending' => Colors.amber.shade300,
|
|
||||||
'promoted' => Colors.blue.shade300,
|
|
||||||
'closed' => Colors.green.shade300,
|
|
||||||
_ => Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _statusTextColor(BuildContext context, String status) {
|
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||||
return switch (status) {
|
if (userId == null || userId.isEmpty) {
|
||||||
'pending' => Colors.brown.shade900,
|
return 'Unassigned';
|
||||||
'promoted' => Colors.blue.shade900,
|
}
|
||||||
'closed' => Colors.green.shade900,
|
final profile = profileById[userId];
|
||||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
if (profile == null) {
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTimestamp(DateTime value) {
|
||||||
|
final year = value.year.toString().padLeft(4, '0');
|
||||||
|
final month = value.month.toString().padLeft(2, '0');
|
||||||
|
final day = value.day.toString().padLeft(2, '0');
|
||||||
|
final hour = value.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = value.minute.toString().padLeft(2, '0');
|
||||||
|
return '$year-$month-$day $hour:$minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusBadge extends StatelessWidget {
|
||||||
|
const _StatusBadge({required this.status});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = switch (status) {
|
||||||
|
'critical' => scheme.errorContainer,
|
||||||
|
'pending' => scheme.tertiaryContainer,
|
||||||
|
'promoted' => scheme.secondaryContainer,
|
||||||
|
'closed' => scheme.primaryContainer,
|
||||||
|
_ => scheme.surfaceContainerHighest,
|
||||||
};
|
};
|
||||||
|
final foreground = switch (status) {
|
||||||
|
'critical' => scheme.onErrorContainer,
|
||||||
|
'pending' => scheme.onTertiaryContainer,
|
||||||
|
'promoted' => scheme.onSecondaryContainer,
|
||||||
|
'closed' => scheme.onPrimaryContainer,
|
||||||
|
_ => scheme.onSurfaceVariant,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Badge(
|
||||||
|
backgroundColor: background,
|
||||||
|
label: Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,39 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
|
import 'app_typography.dart';
|
||||||
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
static ThemeData light() {
|
static ThemeData light() {
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF0C4A6E),
|
seedColor: const Color(0xFF334155),
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
||||||
|
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
||||||
|
final mono = AppMonoText(
|
||||||
|
label:
|
||||||
|
monoTheme.labelMedium?.copyWith(letterSpacing: 0.3) ??
|
||||||
|
const TextStyle(letterSpacing: 0.3),
|
||||||
|
body:
|
||||||
|
monoTheme.bodyMedium?.copyWith(letterSpacing: 0.2) ??
|
||||||
|
const TextStyle(letterSpacing: 0.2),
|
||||||
|
);
|
||||||
|
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
scaffoldBackgroundColor: const Color(0xFFF6F8FA),
|
scaffoldBackgroundColor: base.colorScheme.surfaceContainerLowest,
|
||||||
|
extensions: [mono],
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
foregroundColor: base.colorScheme.onSurface,
|
foregroundColor: base.colorScheme.onSurface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -28,20 +42,26 @@ class AppTheme {
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: base.colorScheme.surface,
|
color: base.colorScheme.surface,
|
||||||
elevation: 0.6,
|
elevation: 0,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(20),
|
||||||
side: BorderSide(color: base.colorScheme.outlineVariant),
|
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: base.colorScheme.surfaceContainerHighest,
|
||||||
|
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
|
labelStyle: textTheme.labelSmall,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: base.colorScheme.outlineVariant,
|
color: base.colorScheme.outlineVariant,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: base.colorScheme.surface,
|
fillColor: base.colorScheme.surfaceContainerLow,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
|
|
@ -72,6 +92,11 @@ class AppTheme {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||||
|
backgroundColor: base.colorScheme.surface,
|
||||||
|
indicatorColor: base.colorScheme.secondaryContainer,
|
||||||
|
tileHeight: 52,
|
||||||
|
),
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||||
|
|
@ -79,6 +104,7 @@ class AppTheme {
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||||
|
indicatorColor: base.colorScheme.secondaryContainer,
|
||||||
),
|
),
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
|
|
@ -88,8 +114,9 @@ class AppTheme {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
tileColor: base.colorScheme.surface,
|
tileColor: base.colorScheme.surface,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -97,21 +124,33 @@ class AppTheme {
|
||||||
static ThemeData dark() {
|
static ThemeData dark() {
|
||||||
final base = ThemeData(
|
final base = ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF38BDF8),
|
seedColor: const Color(0xFF334155),
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
final textTheme = GoogleFonts.spaceGroteskTextTheme(base.textTheme);
|
||||||
|
final monoTheme = GoogleFonts.robotoMonoTextTheme(base.textTheme);
|
||||||
|
final mono = AppMonoText(
|
||||||
|
label:
|
||||||
|
monoTheme.labelMedium?.copyWith(letterSpacing: 0.3) ??
|
||||||
|
const TextStyle(letterSpacing: 0.3),
|
||||||
|
body:
|
||||||
|
monoTheme.bodyMedium?.copyWith(letterSpacing: 0.2) ??
|
||||||
|
const TextStyle(letterSpacing: 0.2),
|
||||||
|
);
|
||||||
|
|
||||||
return base.copyWith(
|
return base.copyWith(
|
||||||
textTheme: textTheme,
|
textTheme: textTheme,
|
||||||
scaffoldBackgroundColor: const Color(0xFF0B111A),
|
scaffoldBackgroundColor: base.colorScheme.surface,
|
||||||
|
extensions: [mono],
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
foregroundColor: base.colorScheme.onSurface,
|
foregroundColor: base.colorScheme.onSurface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 1,
|
||||||
|
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|
@ -119,21 +158,27 @@ class AppTheme {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: const Color(0xFF121A24),
|
color: base.colorScheme.surfaceContainer,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(20),
|
||||||
side: BorderSide(color: base.colorScheme.outlineVariant),
|
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: base.colorScheme.surfaceContainerHighest,
|
||||||
|
side: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
|
labelStyle: textTheme.labelSmall,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
dividerTheme: DividerThemeData(
|
dividerTheme: DividerThemeData(
|
||||||
color: base.colorScheme.outlineVariant,
|
color: base.colorScheme.outlineVariant,
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
),
|
),
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: const Color(0xFF121A24),
|
fillColor: base.colorScheme.surfaceContainerLow,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||||
|
|
@ -164,6 +209,11 @@ class AppTheme {
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||||
|
backgroundColor: base.colorScheme.surface,
|
||||||
|
indicatorColor: base.colorScheme.secondaryContainer,
|
||||||
|
tileHeight: 52,
|
||||||
|
),
|
||||||
navigationRailTheme: NavigationRailThemeData(
|
navigationRailTheme: NavigationRailThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||||
|
|
@ -171,6 +221,7 @@ class AppTheme {
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||||
|
indicatorColor: base.colorScheme.secondaryContainer,
|
||||||
),
|
),
|
||||||
navigationBarTheme: NavigationBarThemeData(
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
backgroundColor: base.colorScheme.surface,
|
backgroundColor: base.colorScheme.surface,
|
||||||
|
|
@ -180,8 +231,9 @@ class AppTheme {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
listTileTheme: ListTileThemeData(
|
listTileTheme: ListTileThemeData(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
tileColor: const Color(0xFF121A24),
|
tileColor: base.colorScheme.surfaceContainer,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
28
lib/theme/app_typography.dart
Normal file
28
lib/theme/app_typography.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class AppMonoText extends ThemeExtension<AppMonoText> {
|
||||||
|
const AppMonoText({required this.label, required this.body});
|
||||||
|
|
||||||
|
final TextStyle label;
|
||||||
|
final TextStyle body;
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppMonoText copyWith({TextStyle? label, TextStyle? body}) {
|
||||||
|
return AppMonoText(label: label ?? this.label, body: body ?? this.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppMonoText lerp(ThemeExtension<AppMonoText>? other, double t) {
|
||||||
|
if (other is! AppMonoText) return this;
|
||||||
|
return AppMonoText(
|
||||||
|
label: TextStyle.lerp(label, other.label, t) ?? label,
|
||||||
|
body: TextStyle.lerp(body, other.body, t) ?? body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static AppMonoText of(BuildContext context) {
|
||||||
|
final ext = Theme.of(context).extension<AppMonoText>();
|
||||||
|
return ext ?? const AppMonoText(label: TextStyle(), body: TextStyle());
|
||||||
|
}
|
||||||
|
}
|
||||||
27
lib/widgets/app_breakpoints.dart
Normal file
27
lib/widgets/app_breakpoints.dart
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
class AppBreakpoints {
|
||||||
|
static const double mobile = 600;
|
||||||
|
static const double tablet = 900;
|
||||||
|
static const double desktop = 1200;
|
||||||
|
|
||||||
|
static bool isMobile(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width < mobile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isTablet(BuildContext context) {
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
return width >= mobile && width < desktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isDesktop(BuildContext context) {
|
||||||
|
return MediaQuery.of(context).size.width >= desktop;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double horizontalPadding(double width) {
|
||||||
|
if (width >= desktop) return 96;
|
||||||
|
if (width >= tablet) return 64;
|
||||||
|
if (width >= mobile) return 32;
|
||||||
|
return 16;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/notifications_provider.dart';
|
import '../providers/notifications_provider.dart';
|
||||||
import '../providers/profile_provider.dart';
|
import '../providers/profile_provider.dart';
|
||||||
|
import 'app_breakpoints.dart';
|
||||||
|
|
||||||
class AppScaffold extends ConsumerWidget {
|
class AppScaffold extends ConsumerWidget {
|
||||||
const AppScaffold({super.key, required this.child});
|
const AppScaffold({super.key, required this.child});
|
||||||
|
|
@ -31,9 +32,15 @@ class AppScaffold extends ConsumerWidget {
|
||||||
final sections = _buildSections(role);
|
final sections = _buildSections(role);
|
||||||
|
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
final showRail = !isStandard && width >= 860;
|
final showRail = width >= AppBreakpoints.tablet;
|
||||||
final isExtended = !isStandard && width >= 1120;
|
final isExtended = width >= AppBreakpoints.desktop;
|
||||||
final showDrawer = !isStandard && !showRail;
|
|
||||||
|
final railItems = _flattenSections(
|
||||||
|
sections,
|
||||||
|
).where((item) => !item.isLogout).toList();
|
||||||
|
final primaryItems = _primaryItemsForRole(role);
|
||||||
|
final mobilePrimary = _mobilePrimaryItems(primaryItems);
|
||||||
|
final overflowItems = _overflowItems(railItems, mobilePrimary);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
|
@ -78,48 +85,61 @@ class AppScaffold extends ConsumerWidget {
|
||||||
const _NotificationBell(),
|
const _NotificationBell(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: showDrawer
|
bottomNavigationBar: showRail
|
||||||
? Drawer(
|
? null
|
||||||
child: AppSideNav(
|
: AppBottomNav(
|
||||||
sections: sections,
|
|
||||||
location: location,
|
location: location,
|
||||||
extended: true,
|
items: _mobileNavItems(mobilePrimary, overflowItems),
|
||||||
displayName: displayName,
|
onShowMore: overflowItems.isEmpty
|
||||||
onLogout: () => ref.read(authControllerProvider).signOut(),
|
? null
|
||||||
|
: () => _showOverflowSheet(
|
||||||
|
context,
|
||||||
|
overflowItems,
|
||||||
|
() => ref.read(authControllerProvider).signOut(),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
: null,
|
body: LayoutBuilder(
|
||||||
bottomNavigationBar: isStandard
|
builder: (context, constraints) {
|
||||||
? AppBottomNav(location: location, items: _standardNavItems())
|
final railWidth = showRail ? (isExtended ? 256.0 : 80.0) : 0.0;
|
||||||
: null,
|
return Stack(
|
||||||
body: Row(
|
|
||||||
children: [
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
left: railWidth,
|
||||||
|
child: _ShellBackground(child: child),
|
||||||
|
),
|
||||||
if (showRail)
|
if (showRail)
|
||||||
AppSideNav(
|
Positioned(
|
||||||
sections: sections,
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: railWidth,
|
||||||
|
child: AppNavigationRail(
|
||||||
|
items: railItems,
|
||||||
location: location,
|
location: location,
|
||||||
extended: isExtended,
|
extended: isExtended,
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
onLogout: () => ref.read(authControllerProvider).signOut(),
|
onLogout: () => ref.read(authControllerProvider).signOut(),
|
||||||
),
|
),
|
||||||
Expanded(child: _ShellBackground(child: child)),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppSideNav extends StatelessWidget {
|
class AppNavigationRail extends StatelessWidget {
|
||||||
const AppSideNav({
|
const AppNavigationRail({
|
||||||
super.key,
|
super.key,
|
||||||
required this.sections,
|
required this.items,
|
||||||
required this.location,
|
required this.location,
|
||||||
required this.extended,
|
required this.extended,
|
||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.onLogout,
|
required this.onLogout,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<NavSection> sections;
|
final List<NavItem> items;
|
||||||
final String location;
|
final String location;
|
||||||
final bool extended;
|
final bool extended;
|
||||||
final String displayName;
|
final String displayName;
|
||||||
|
|
@ -127,9 +147,8 @@ class AppSideNav extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final width = extended ? 240.0 : 72.0;
|
final currentIndex = _currentIndex(location, items);
|
||||||
return Container(
|
return Container(
|
||||||
width: width,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
border: Border(
|
border: Border(
|
||||||
|
|
@ -138,70 +157,38 @@ class AppSideNav extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ListView(
|
child: NavigationRail(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: EdgeInsets.symmetric(
|
|
||||||
horizontal: extended ? 16 : 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Image.asset(
|
|
||||||
'assets/tasq_ico.png',
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
),
|
|
||||||
if (extended) ...[
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
displayName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
for (final section in sections) ...[
|
|
||||||
if (section.label != null && extended)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
section.label!,
|
|
||||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
||||||
letterSpacing: 0.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (final item in section.items)
|
|
||||||
_NavTile(
|
|
||||||
item: item,
|
|
||||||
selected: _isSelected(location, item.route),
|
|
||||||
extended: extended,
|
extended: extended,
|
||||||
onLogout: onLogout,
|
selectedIndex: currentIndex,
|
||||||
|
onDestinationSelected: (value) {
|
||||||
|
items[value].onTap(context, onLogout: onLogout);
|
||||||
|
},
|
||||||
|
leading: const SizedBox.shrink(),
|
||||||
|
trailing: const SizedBox.shrink(),
|
||||||
|
destinations: [
|
||||||
|
for (final item in items)
|
||||||
|
NavigationRailDestination(
|
||||||
|
icon: Icon(item.icon),
|
||||||
|
selectedIcon: Icon(item.selectedIcon ?? item.icon),
|
||||||
|
label: Text(item.label),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppBottomNav extends StatelessWidget {
|
class AppBottomNav extends StatelessWidget {
|
||||||
const AppBottomNav({super.key, required this.location, required this.items});
|
const AppBottomNav({
|
||||||
|
super.key,
|
||||||
|
required this.location,
|
||||||
|
required this.items,
|
||||||
|
this.onShowMore,
|
||||||
|
});
|
||||||
|
|
||||||
final String location;
|
final String location;
|
||||||
final List<NavItem> items;
|
final List<NavItem> items;
|
||||||
|
final VoidCallback? onShowMore;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -209,10 +196,12 @@ class AppBottomNav extends StatelessWidget {
|
||||||
return NavigationBar(
|
return NavigationBar(
|
||||||
selectedIndex: index,
|
selectedIndex: index,
|
||||||
onDestinationSelected: (value) {
|
onDestinationSelected: (value) {
|
||||||
final target = items[value].route;
|
final item = items[value];
|
||||||
if (target.isNotEmpty) {
|
if (item.isOverflow) {
|
||||||
context.go(target);
|
onShowMore?.call();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
item.onTap(context);
|
||||||
},
|
},
|
||||||
destinations: [
|
destinations: [
|
||||||
for (final item in items)
|
for (final item in items)
|
||||||
|
|
@ -226,50 +215,6 @@ class AppBottomNav extends StatelessWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NavTile extends StatelessWidget {
|
|
||||||
const _NavTile({
|
|
||||||
required this.item,
|
|
||||||
required this.selected,
|
|
||||||
required this.extended,
|
|
||||||
required this.onLogout,
|
|
||||||
});
|
|
||||||
|
|
||||||
final NavItem item;
|
|
||||||
final bool selected;
|
|
||||||
final bool extended;
|
|
||||||
final VoidCallback onLogout;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final iconColor = selected ? colorScheme.primary : colorScheme.onSurface;
|
|
||||||
final background = selected
|
|
||||||
? colorScheme.primaryContainer.withValues(alpha: 0.6)
|
|
||||||
: Colors.transparent;
|
|
||||||
|
|
||||||
final content = Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: background,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: ListTile(
|
|
||||||
leading: Icon(item.icon, color: iconColor),
|
|
||||||
title: extended ? Text(item.label) : null,
|
|
||||||
onTap: () => item.onTap(context, onLogout: onLogout),
|
|
||||||
dense: true,
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (extended) {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Tooltip(message: item.label, child: content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NotificationBell extends ConsumerWidget {
|
class _NotificationBell extends ConsumerWidget {
|
||||||
const _NotificationBell();
|
const _NotificationBell();
|
||||||
|
|
||||||
|
|
@ -325,6 +270,7 @@ class NavItem {
|
||||||
required this.icon,
|
required this.icon,
|
||||||
this.selectedIcon,
|
this.selectedIcon,
|
||||||
this.isLogout = false,
|
this.isLogout = false,
|
||||||
|
this.isOverflow = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
|
|
@ -332,6 +278,7 @@ class NavItem {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final IconData? selectedIcon;
|
final IconData? selectedIcon;
|
||||||
final bool isLogout;
|
final bool isLogout;
|
||||||
|
final bool isOverflow;
|
||||||
|
|
||||||
void onTap(BuildContext context, {VoidCallback? onLogout}) {
|
void onTap(BuildContext context, {VoidCallback? onLogout}) {
|
||||||
if (isLogout) {
|
if (isLogout) {
|
||||||
|
|
@ -458,6 +405,106 @@ List<NavItem> _standardNavItems() {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<NavItem> _primaryItemsForRole(String role) {
|
||||||
|
if (role == 'admin') {
|
||||||
|
return [
|
||||||
|
NavItem(
|
||||||
|
label: 'Dashboard',
|
||||||
|
route: '/dashboard',
|
||||||
|
icon: Icons.grid_view_outlined,
|
||||||
|
selectedIcon: Icons.grid_view_rounded,
|
||||||
|
),
|
||||||
|
NavItem(
|
||||||
|
label: 'Tickets',
|
||||||
|
route: '/tickets',
|
||||||
|
icon: Icons.support_agent_outlined,
|
||||||
|
selectedIcon: Icons.support_agent,
|
||||||
|
),
|
||||||
|
NavItem(
|
||||||
|
label: 'Tasks',
|
||||||
|
route: '/tasks',
|
||||||
|
icon: Icons.task_outlined,
|
||||||
|
selectedIcon: Icons.task,
|
||||||
|
),
|
||||||
|
NavItem(
|
||||||
|
label: 'Workforce',
|
||||||
|
route: '/workforce',
|
||||||
|
icon: Icons.groups_outlined,
|
||||||
|
selectedIcon: Icons.groups,
|
||||||
|
),
|
||||||
|
NavItem(
|
||||||
|
label: 'Reports',
|
||||||
|
route: '/reports',
|
||||||
|
icon: Icons.analytics_outlined,
|
||||||
|
selectedIcon: Icons.analytics,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return _standardNavItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NavItem> _mobileNavItems(List<NavItem> primary, List<NavItem> overflow) {
|
||||||
|
if (overflow.isEmpty) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...primary,
|
||||||
|
NavItem(label: 'More', route: '', icon: Icons.more_horiz, isOverflow: true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NavItem> _mobilePrimaryItems(List<NavItem> primary) {
|
||||||
|
if (primary.length <= 4) {
|
||||||
|
return primary;
|
||||||
|
}
|
||||||
|
return primary.take(4).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NavItem> _flattenSections(List<NavSection> sections) {
|
||||||
|
return [for (final section in sections) ...section.items];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<NavItem> _overflowItems(List<NavItem> all, List<NavItem> primary) {
|
||||||
|
final primaryRoutes = primary.map((item) => item.route).toSet();
|
||||||
|
return all
|
||||||
|
.where(
|
||||||
|
(item) =>
|
||||||
|
!item.isLogout &&
|
||||||
|
item.route.isNotEmpty &&
|
||||||
|
!primaryRoutes.contains(item.route),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showOverflowSheet(
|
||||||
|
BuildContext context,
|
||||||
|
List<NavItem> items,
|
||||||
|
VoidCallback onLogout,
|
||||||
|
) async {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
for (final item in items)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(item.icon),
|
||||||
|
title: Text(item.label),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
item.onTap(context, onLogout: onLogout);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool _isSelected(String location, String route) {
|
bool _isSelected(String location, String route) {
|
||||||
if (route.isEmpty) return false;
|
if (route.isEmpty) return false;
|
||||||
if (location == route) return true;
|
if (location == route) return true;
|
||||||
|
|
@ -466,5 +513,7 @@ bool _isSelected(String location, String route) {
|
||||||
|
|
||||||
int _currentIndex(String location, List<NavItem> items) {
|
int _currentIndex(String location, List<NavItem> items) {
|
||||||
final index = items.indexWhere((item) => _isSelected(location, item.route));
|
final index = items.indexWhere((item) => _isSelected(location, item.route));
|
||||||
return index == -1 ? 0 : index;
|
if (index != -1) return index;
|
||||||
|
final overflowIndex = items.indexWhere((item) => item.isOverflow);
|
||||||
|
return overflowIndex == -1 ? 0 : overflowIndex;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
32
lib/widgets/mono_text.dart
Normal file
32
lib/widgets/mono_text.dart
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_typography.dart';
|
||||||
|
|
||||||
|
class MonoText extends StatelessWidget {
|
||||||
|
const MonoText(
|
||||||
|
this.text, {
|
||||||
|
super.key,
|
||||||
|
this.style,
|
||||||
|
this.maxLines,
|
||||||
|
this.overflow,
|
||||||
|
this.textAlign,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String text;
|
||||||
|
final TextStyle? style;
|
||||||
|
final int? maxLines;
|
||||||
|
final TextOverflow? overflow;
|
||||||
|
final TextAlign? textAlign;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final base = AppMonoText.of(context).body;
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: base.merge(style),
|
||||||
|
maxLines: maxLines,
|
||||||
|
overflow: overflow,
|
||||||
|
textAlign: textAlign,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import 'app_breakpoints.dart';
|
||||||
|
|
||||||
class ResponsiveBody extends StatelessWidget {
|
class ResponsiveBody extends StatelessWidget {
|
||||||
const ResponsiveBody({
|
const ResponsiveBody({
|
||||||
super.key,
|
super.key,
|
||||||
|
|
@ -16,13 +18,18 @@ class ResponsiveBody extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final width = constraints.maxWidth;
|
final height = constraints.hasBoundedHeight
|
||||||
final horizontalPadding = switch (width) {
|
? constraints.maxHeight
|
||||||
>= 1200 => 96.0,
|
: MediaQuery.sizeOf(context).height;
|
||||||
>= 900 => 64.0,
|
final width = constraints.hasBoundedWidth
|
||||||
>= 600 => 32.0,
|
? constraints.maxWidth
|
||||||
_ => 16.0,
|
: maxWidth;
|
||||||
};
|
final horizontalPadding = AppBreakpoints.horizontalPadding(width);
|
||||||
|
final boxConstraints = BoxConstraints(
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
minHeight: height,
|
||||||
|
maxHeight: height,
|
||||||
|
);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: padding.add(
|
padding: padding.add(
|
||||||
|
|
@ -31,12 +38,8 @@ class ResponsiveBody extends StatelessWidget {
|
||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
constraints: boxConstraints,
|
||||||
child: SizedBox(
|
child: SizedBox(width: width, child: child),
|
||||||
width: double.infinity,
|
|
||||||
height: constraints.maxHeight,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
35
lib/widgets/status_pill.dart
Normal file
35
lib/widgets/status_pill.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class StatusPill extends StatelessWidget {
|
||||||
|
const StatusPill({super.key, required this.label, this.isEmphasized = false});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool isEmphasized;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = isEmphasized
|
||||||
|
? scheme.tertiaryContainer
|
||||||
|
: scheme.tertiaryContainer.withValues(alpha: 0.65);
|
||||||
|
final foreground = scheme.onTertiaryContainer;
|
||||||
|
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: scheme.tertiary.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label.toUpperCase(),
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
lib/widgets/tasq_adaptive_list.dart
Normal file
251
lib/widgets/tasq_adaptive_list.dart
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_typography.dart';
|
||||||
|
import 'mono_text.dart';
|
||||||
|
|
||||||
|
class TasQColumn<T> {
|
||||||
|
const TasQColumn({
|
||||||
|
required this.header,
|
||||||
|
required this.cellBuilder,
|
||||||
|
this.technical = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String header;
|
||||||
|
final Widget Function(BuildContext context, T item) cellBuilder;
|
||||||
|
final bool technical;
|
||||||
|
}
|
||||||
|
|
||||||
|
typedef TasQMobileTileBuilder<T> =
|
||||||
|
Widget Function(BuildContext context, T item, List<Widget> actions);
|
||||||
|
|
||||||
|
typedef TasQRowActions<T> = List<Widget> Function(T item);
|
||||||
|
|
||||||
|
typedef TasQRowTap<T> = void Function(T item);
|
||||||
|
|
||||||
|
class TasQAdaptiveList<T> extends StatelessWidget {
|
||||||
|
const TasQAdaptiveList({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.columns,
|
||||||
|
required this.mobileTileBuilder,
|
||||||
|
this.rowActions,
|
||||||
|
this.onRowTap,
|
||||||
|
this.rowsPerPage = 25,
|
||||||
|
this.tableHeader,
|
||||||
|
this.filterHeader,
|
||||||
|
this.summaryDashboard,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<T> items;
|
||||||
|
final List<TasQColumn<T>> columns;
|
||||||
|
final TasQMobileTileBuilder<T> mobileTileBuilder;
|
||||||
|
final TasQRowActions<T>? rowActions;
|
||||||
|
final TasQRowTap<T>? onRowTap;
|
||||||
|
final int rowsPerPage;
|
||||||
|
final Widget? tableHeader;
|
||||||
|
final Widget? filterHeader;
|
||||||
|
final Widget? summaryDashboard;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final isMobile = constraints.maxWidth < 600;
|
||||||
|
final hasBoundedHeight = constraints.hasBoundedHeight;
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
final listView = ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
|
return mobileTileBuilder(context, item, actions);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final shrinkWrappedList = ListView.separated(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24),
|
||||||
|
itemCount: items.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
final actions = rowActions?.call(item) ?? const <Widget>[];
|
||||||
|
return mobileTileBuilder(context, item, actions);
|
||||||
|
},
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
);
|
||||||
|
final summarySection = summaryDashboard == null
|
||||||
|
? null
|
||||||
|
: <Widget>[
|
||||||
|
SizedBox(width: double.infinity, child: summaryDashboard!),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
];
|
||||||
|
final filterSection = filterHeader == null
|
||||||
|
? null
|
||||||
|
: <Widget>[
|
||||||
|
ExpansionTile(
|
||||||
|
title: const Text('Filters'),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: filterHeader!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
...?summarySection,
|
||||||
|
...?filterSection,
|
||||||
|
if (hasBoundedHeight) Expanded(child: listView),
|
||||||
|
if (!hasBoundedHeight) shrinkWrappedList,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final dataSource = _TasQTableSource<T>(
|
||||||
|
context: context,
|
||||||
|
items: items,
|
||||||
|
columns: columns,
|
||||||
|
rowActions: rowActions,
|
||||||
|
onRowTap: onRowTap,
|
||||||
|
);
|
||||||
|
final contentWidth = constraints.maxWidth * 0.8;
|
||||||
|
final tableWidth = math.max(
|
||||||
|
contentWidth,
|
||||||
|
(columns.length + (rowActions == null ? 0 : 1)) * 140.0,
|
||||||
|
);
|
||||||
|
final effectiveRowsPerPage = math.min(
|
||||||
|
rowsPerPage,
|
||||||
|
math.max(1, items.length),
|
||||||
|
);
|
||||||
|
final tableWidget = SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: SizedBox(
|
||||||
|
width: tableWidth,
|
||||||
|
child: PaginatedDataTable(
|
||||||
|
header: tableHeader,
|
||||||
|
rowsPerPage: effectiveRowsPerPage,
|
||||||
|
columnSpacing: 20,
|
||||||
|
horizontalMargin: 16,
|
||||||
|
showCheckboxColumn: false,
|
||||||
|
headingRowColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => Theme.of(context).colorScheme.surfaceContainer,
|
||||||
|
),
|
||||||
|
columns: [
|
||||||
|
for (final column in columns)
|
||||||
|
DataColumn(label: Text(column.header)),
|
||||||
|
if (rowActions != null)
|
||||||
|
const DataColumn(label: Text('Actions')),
|
||||||
|
],
|
||||||
|
source: dataSource,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final summarySection = summaryDashboard == null
|
||||||
|
? null
|
||||||
|
: <Widget>[
|
||||||
|
SizedBox(width: contentWidth, child: summaryDashboard!),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
];
|
||||||
|
final filterSection = filterHeader == null
|
||||||
|
? null
|
||||||
|
: <Widget>[filterHeader!, const SizedBox(height: 12)];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
primary: hasBoundedHeight,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: contentWidth,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [...?summarySection, ...?filterSection, tableWidget],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TasQTableSource<T> extends DataTableSource {
|
||||||
|
_TasQTableSource({
|
||||||
|
required this.context,
|
||||||
|
required this.items,
|
||||||
|
required this.columns,
|
||||||
|
required this.rowActions,
|
||||||
|
required this.onRowTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final BuildContext context;
|
||||||
|
final List<T> items;
|
||||||
|
final List<TasQColumn<T>> columns;
|
||||||
|
final TasQRowActions<T>? rowActions;
|
||||||
|
final TasQRowTap<T>? onRowTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
DataRow? getRow(int index) {
|
||||||
|
if (index >= items.length) return null;
|
||||||
|
final item = items[index];
|
||||||
|
final cells = <DataCell>[];
|
||||||
|
|
||||||
|
for (final column in columns) {
|
||||||
|
final widget = column.cellBuilder(context, item);
|
||||||
|
cells.add(DataCell(_applyTechnicalStyle(context, widget, column)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowActions != null) {
|
||||||
|
final actions = rowActions!.call(item);
|
||||||
|
cells.add(
|
||||||
|
DataCell(Row(mainAxisSize: MainAxisSize.min, children: actions)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DataRow(
|
||||||
|
onSelectChanged: onRowTap == null ? null : (_) => onRowTap!(item),
|
||||||
|
cells: cells,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isRowCountApproximate => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get rowCount => items.length;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get selectedRowCount => 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _applyTechnicalStyle<T>(
|
||||||
|
BuildContext context,
|
||||||
|
Widget child,
|
||||||
|
TasQColumn<T> column,
|
||||||
|
) {
|
||||||
|
if (!column.technical) return child;
|
||||||
|
if (child is Text && child.data != null) {
|
||||||
|
return MonoText(
|
||||||
|
child.data ?? '',
|
||||||
|
style: child.style,
|
||||||
|
maxLines: child.maxLines,
|
||||||
|
overflow: child.overflow,
|
||||||
|
textAlign: child.textAlign,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final mono = AppMonoText.of(context).body;
|
||||||
|
return DefaultTextStyle.merge(style: mono, child: child);
|
||||||
|
}
|
||||||
182
test/layout_smoke_test.dart
Normal file
182
test/layout_smoke_test.dart
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
|
|
||||||
|
import 'package:tasq/models/notification_item.dart';
|
||||||
|
import 'package:tasq/models/office.dart';
|
||||||
|
import 'package:tasq/models/profile.dart';
|
||||||
|
import 'package:tasq/models/task.dart';
|
||||||
|
import 'package:tasq/models/ticket.dart';
|
||||||
|
import 'package:tasq/models/user_office.dart';
|
||||||
|
import 'package:tasq/providers/notifications_provider.dart';
|
||||||
|
import 'package:tasq/providers/profile_provider.dart';
|
||||||
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
|
import 'package:tasq/providers/tickets_provider.dart';
|
||||||
|
import 'package:tasq/providers/typing_provider.dart';
|
||||||
|
import 'package:tasq/providers/user_offices_provider.dart';
|
||||||
|
import 'package:tasq/screens/admin/offices_screen.dart';
|
||||||
|
import 'package:tasq/screens/admin/user_management_screen.dart';
|
||||||
|
import 'package:tasq/screens/tasks/tasks_list_screen.dart';
|
||||||
|
import 'package:tasq/screens/tickets/tickets_list_screen.dart';
|
||||||
|
|
||||||
|
SupabaseClient _fakeSupabaseClient() {
|
||||||
|
return SupabaseClient('http://localhost', 'test-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final now = DateTime(2026, 2, 10, 12, 0, 0);
|
||||||
|
final office = Office(id: 'office-1', name: 'HQ');
|
||||||
|
final admin = Profile(id: 'user-1', role: 'admin', fullName: 'Alex Admin');
|
||||||
|
final tech = Profile(id: 'user-2', role: 'it_staff', fullName: 'Jamie Tech');
|
||||||
|
final ticket = Ticket(
|
||||||
|
id: 'TCK-1',
|
||||||
|
subject: 'Printer down',
|
||||||
|
description: 'Paper jam and offline',
|
||||||
|
officeId: 'office-1',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: now,
|
||||||
|
creatorId: 'user-1',
|
||||||
|
respondedAt: null,
|
||||||
|
promotedAt: null,
|
||||||
|
closedAt: null,
|
||||||
|
);
|
||||||
|
final task = Task(
|
||||||
|
id: 'TSK-1',
|
||||||
|
ticketId: 'TCK-1',
|
||||||
|
title: 'Reboot printer',
|
||||||
|
description: 'Clear queue and reboot',
|
||||||
|
officeId: 'office-1',
|
||||||
|
status: 'queued',
|
||||||
|
priority: 1,
|
||||||
|
queueOrder: 1,
|
||||||
|
createdAt: now,
|
||||||
|
creatorId: 'user-2',
|
||||||
|
startedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
);
|
||||||
|
final notification = NotificationItem(
|
||||||
|
id: 'N-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
actorId: 'user-2',
|
||||||
|
ticketId: 'TCK-1',
|
||||||
|
taskId: null,
|
||||||
|
messageId: 1,
|
||||||
|
type: 'mention',
|
||||||
|
createdAt: now,
|
||||||
|
readAt: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Override> baseOverrides() {
|
||||||
|
return [
|
||||||
|
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
||||||
|
profilesProvider.overrideWith((ref) => Stream.value([admin, tech])),
|
||||||
|
officesProvider.overrideWith((ref) => Stream.value([office])),
|
||||||
|
notificationsProvider.overrideWith((ref) => Stream.value([notification])),
|
||||||
|
ticketsProvider.overrideWith((ref) => Stream.value([ticket])),
|
||||||
|
tasksProvider.overrideWith((ref) => Stream.value([task])),
|
||||||
|
userOfficesProvider.overrideWith(
|
||||||
|
(ref) =>
|
||||||
|
Stream.value([UserOffice(userId: 'user-1', officeId: 'office-1')]),
|
||||||
|
),
|
||||||
|
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
||||||
|
isAdminProvider.overrideWith((ref) => true),
|
||||||
|
typingIndicatorProvider.overrideWithProvider(
|
||||||
|
AutoDisposeStateNotifierProvider.family<
|
||||||
|
TypingIndicatorController,
|
||||||
|
TypingIndicatorState,
|
||||||
|
String
|
||||||
|
>((ref, id) => TypingIndicatorController(_fakeSupabaseClient(), id)),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Override> userManagementOverrides() {
|
||||||
|
return [
|
||||||
|
currentProfileProvider.overrideWith((ref) => Stream.value(admin)),
|
||||||
|
profilesProvider.overrideWith((ref) => Stream.value(const <Profile>[])),
|
||||||
|
officesProvider.overrideWith((ref) => Stream.value([office])),
|
||||||
|
userOfficesProvider.overrideWith(
|
||||||
|
(ref) => Stream.value(const <UserOffice>[]),
|
||||||
|
),
|
||||||
|
ticketMessagesAllProvider.overrideWith((ref) => const Stream.empty()),
|
||||||
|
isAdminProvider.overrideWith((ref) => true),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
group('Layout smoke tests', () {
|
||||||
|
testWidgets('Tickets list renders without layout exceptions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||||
|
await _pumpScreen(
|
||||||
|
tester,
|
||||||
|
const TicketsListScreen(),
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Tasks list renders without layout exceptions', (tester) async {
|
||||||
|
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||||
|
await _pumpScreen(
|
||||||
|
tester,
|
||||||
|
const TasksListScreen(),
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('Offices screen renders without layout exceptions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||||
|
await _pumpScreen(
|
||||||
|
tester,
|
||||||
|
const OfficesScreen(),
|
||||||
|
overrides: baseOverrides(),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('User management renders without layout exceptions', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||||
|
await _pumpScreen(
|
||||||
|
tester,
|
||||||
|
const UserManagementScreen(),
|
||||||
|
overrides: userManagementOverrides(),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 16));
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpScreen(
|
||||||
|
WidgetTester tester,
|
||||||
|
Widget child, {
|
||||||
|
required List<Override> overrides,
|
||||||
|
}) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ProviderScope(
|
||||||
|
overrides: overrides,
|
||||||
|
child: MaterialApp(home: Scaffold(body: child)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setSurfaceSize(WidgetTester tester, Size size) async {
|
||||||
|
await tester.binding.setSurfaceSize(size);
|
||||||
|
addTearDown(() async {
|
||||||
|
await tester.binding.setSurfaceSize(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user