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 '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
|
||||
class OfficesScreen extends ConsumerWidget {
|
||||
class OfficesScreen extends ConsumerStatefulWidget {
|
||||
const OfficesScreen({super.key});
|
||||
|
||||
@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 officesAsync = ref.watch(officesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 800,
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: !isAdmin
|
||||
? const Center(child: Text('Admin access required.'))
|
||||
: officesAsync.when(
|
||||
|
|
@ -25,78 +41,122 @@ class OfficesScreen extends ConsumerWidget {
|
|||
if (offices.isEmpty) {
|
||||
return const Center(child: Text('No offices found.'));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Office Management',
|
||||
style: Theme.of(context).textTheme.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
|
||||
final query = _searchController.text.trim().toLowerCase();
|
||||
final filteredOffices = query.isEmpty
|
||||
? offices
|
||||
: offices
|
||||
.where(
|
||||
(office) =>
|
||||
office.name.toLowerCase().contains(query) ||
|
||||
office.id.toLowerCase().contains(query),
|
||||
)
|
||||
.toList();
|
||||
|
||||
final listBody = TasQAdaptiveList<Office>(
|
||||
items: filteredOffices,
|
||||
filterHeader: SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Search name',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () => context.go('/settings/users'),
|
||||
icon: const Icon(Icons.group),
|
||||
label: const Text('User access'),
|
||||
),
|
||||
columns: [
|
||||
TasQColumn<Office>(
|
||||
header: 'Office ID',
|
||||
technical: true,
|
||||
cellBuilder: (context, office) => Text(office.id),
|
||||
),
|
||||
TasQColumn<Office>(
|
||||
header: 'Office Name',
|
||||
cellBuilder: (context, office) => Text(office.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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: [
|
||||
rowActions: (office) => [
|
||||
IconButton(
|
||||
tooltip: 'Edit',
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => _showOfficeDialog(
|
||||
context,
|
||||
ref,
|
||||
office: office,
|
||||
),
|
||||
onPressed: () =>
|
||||
_showOfficeDialog(context, ref, office: office),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: 'Delete',
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _confirmDelete(context, ref, office),
|
||||
),
|
||||
],
|
||||
mobileTileBuilder: (context, office, actions) {
|
||||
return Card(
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: const Icon(Icons.apartment_outlined),
|
||||
title: Text(office.name),
|
||||
subtitle: MonoText('ID ${office.id}'),
|
||||
trailing: Wrap(spacing: 8, children: actions),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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: () =>
|
||||
_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, _) =>
|
||||
Center(child: Text('Failed to load offices: $error')),
|
||||
),
|
||||
),
|
||||
floatingActionButton: isAdmin
|
||||
? FloatingActionButton.extended(
|
||||
if (isAdmin)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _showOfficeDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Office'),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import '../../providers/admin_user_provider.dart';
|
|||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/user_offices_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
|
||||
class UserManagementScreen extends ConsumerStatefulWidget {
|
||||
const UserManagementScreen({super.key});
|
||||
|
|
@ -28,6 +30,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
];
|
||||
|
||||
final _fullNameController = TextEditingController();
|
||||
final _searchController = TextEditingController();
|
||||
|
||||
String? _selectedUserId;
|
||||
String? _selectedRole;
|
||||
|
|
@ -43,6 +46,7 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
@override
|
||||
void dispose() {
|
||||
_fullNameController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -54,9 +58,8 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
final assignmentsAsync = ref.watch(userOfficesProvider);
|
||||
final messagesAsync = ref.watch(ticketMessagesAllProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
maxWidth: 1080,
|
||||
return ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: !isAdmin
|
||||
? const Center(child: Text('Admin access required.'))
|
||||
: _buildContent(
|
||||
|
|
@ -66,7 +69,6 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
assignmentsAsync,
|
||||
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) {
|
||||
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>{};
|
||||
for (final assignment in assignments) {
|
||||
|
|
@ -163,140 +139,127 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 720),
|
||||
child: SingleChildScrollView(
|
||||
child: DataTable(
|
||||
headingRowHeight: 46,
|
||||
dataRowMinHeight: 48,
|
||||
dataRowMaxHeight: 64,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
dividerThickness: 1,
|
||||
headingRowColor: WidgetStateProperty.resolveWith(
|
||||
(states) =>
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
final listBody = TasQAdaptiveList<Profile>(
|
||||
items: filteredProfiles,
|
||||
filterHeader: SizedBox(
|
||||
width: 320,
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Search name',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
columns: const [
|
||||
DataColumn(label: Text('User')),
|
||||
DataColumn(label: Text('Email')),
|
||||
DataColumn(label: Text('Role')),
|
||||
DataColumn(label: Text('Offices')),
|
||||
DataColumn(label: Text('Status')),
|
||||
DataColumn(label: Text('Last active')),
|
||||
],
|
||||
rows: profiles.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final profile = entry.value;
|
||||
final label = profile.fullName.isEmpty
|
||||
? profile.id
|
||||
: profile.fullName;
|
||||
),
|
||||
),
|
||||
columns: [
|
||||
TasQColumn<Profile>(
|
||||
header: 'User',
|
||||
cellBuilder: (context, profile) {
|
||||
final label =
|
||||
profile.fullName.isEmpty ? profile.id : profile.fullName;
|
||||
return Text(label);
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Email',
|
||||
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 hasError = _statusErrors.contains(profile.id);
|
||||
final isLoading = _statusLoading.contains(profile.id);
|
||||
final email = hasError
|
||||
? 'Unavailable'
|
||||
: (status?.email ?? (isLoading ? 'Loading...' : 'N/A'));
|
||||
final statusLabel = hasError
|
||||
? 'Unavailable'
|
||||
: (status == null
|
||||
? (isLoading ? 'Loading...' : 'Unknown')
|
||||
: (status.isLocked ? 'Locked' : 'Active'));
|
||||
final officeCount = officeCountByUser[profile.id] ?? 0;
|
||||
final officeLabel = officeCount == 0 ? 'None' : '$officeCount';
|
||||
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);
|
||||
final statusLabel =
|
||||
_userStatusLabel(status, hasError, isLoading);
|
||||
return _StatusBadge(label: statusLabel);
|
||||
},
|
||||
),
|
||||
TasQColumn<Profile>(
|
||||
header: 'Last active',
|
||||
cellBuilder: (context, profile) {
|
||||
final lastActive = lastActiveByUser[profile.id];
|
||||
return Text(_formatLastActiveLabel(lastActive));
|
||||
},
|
||||
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)),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
onRowTap: (profile) =>
|
||||
_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) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'User Management',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: listBody),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
BuildContext context,
|
||||
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';
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(value);
|
||||
|
|
@ -669,5 +682,34 @@ class _UserManagementScreenState extends ConsumerState<UserManagementScreen> {
|
|||
final month = value.month.toString().padLeft(2, '0');
|
||||
final day = value.day.toString().padLeft(2, '0');
|
||||
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/tickets_provider.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
|
||||
class DashboardMetrics {
|
||||
DashboardMetrics({
|
||||
|
|
@ -265,8 +267,63 @@ class DashboardScreen extends StatelessWidget {
|
|||
return ResponsiveBody(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 980;
|
||||
final metricsColumn = Column(
|
||||
final sections = <Widget>[
|
||||
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,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
|
@ -284,113 +341,16 @@ class DashboardScreen extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
const _DashboardStatusBanner(),
|
||||
_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, '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(),
|
||||
),
|
||||
]),
|
||||
...sections,
|
||||
],
|
||||
);
|
||||
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
metricsColumn,
|
||||
const SizedBox(height: 12),
|
||||
staffColumn,
|
||||
],
|
||||
),
|
||||
child: content,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -415,21 +375,42 @@ class DashboardScreen extends StatelessWidget {
|
|||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final columns = width >= 900
|
||||
? 3
|
||||
: width >= 620
|
||||
? 2
|
||||
: 1;
|
||||
if (width < 520) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < cards.length; i++) ...[
|
||||
cards[i],
|
||||
if (i < cards.length - 1) const SizedBox(height: 12),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
final spacing = 12.0;
|
||||
final cardWidth = (width - (columns - 1) * spacing) / columns;
|
||||
return Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
runAlignment: WrapAlignment.center,
|
||||
spacing: spacing,
|
||||
runSpacing: spacing,
|
||||
children: cards
|
||||
.map((card) => SizedBox(width: cardWidth, child: card))
|
||||
.toList(),
|
||||
final minCardWidth = 220.0;
|
||||
final totalWidth =
|
||||
cards.length * minCardWidth + spacing * (cards.length - 1);
|
||||
final fits = totalWidth <= width;
|
||||
final cardWidth = fits
|
||||
? (width - spacing * (cards.length - 1)) / cards.length
|
||||
: minCardWidth;
|
||||
|
||||
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',
|
||||
);
|
||||
|
||||
return Container(
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
|
|
@ -493,7 +475,7 @@ class _MetricCard extends ConsumerWidget {
|
|||
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
MonoText(
|
||||
value,
|
||||
style: Theme.of(
|
||||
context,
|
||||
|
|
@ -587,7 +569,13 @@ class _StaffRow extends StatelessWidget {
|
|||
child: Row(
|
||||
children: [
|
||||
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(
|
||||
flex: 2,
|
||||
child: Text(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import '../../providers/notifications_provider.dart';
|
|||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
|
||||
class NotificationsScreen extends ConsumerWidget {
|
||||
|
|
@ -73,10 +74,21 @@ class NotificationsScreen extends ConsumerWidget {
|
|||
final title = _notificationTitle(item.type, actorName);
|
||||
final icon = _notificationIcon(item.type);
|
||||
|
||||
return ListTile(
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(icon),
|
||||
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
|
||||
? const Icon(
|
||||
Icons.circle,
|
||||
|
|
@ -107,6 +119,7 @@ class NotificationsScreen extends ConsumerWidget {
|
|||
context.go('/tickets/$ticketId');
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@ import '../../providers/profile_provider.dart';
|
|||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
|
|
@ -102,11 +105,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
);
|
||||
|
||||
return ResponsiveBody(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
||||
child: Column(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
|
||||
|
||||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
|
|
@ -114,9 +117,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
child: Text(
|
||||
task.title.isNotEmpty ? task.title : 'Task ${task.id}',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
|
@ -128,14 +131,15 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_buildStatusChip(context, task, canUpdateStatus),
|
||||
Text('Office: $officeName'),
|
||||
_MetaBadge(label: 'Office', value: officeName),
|
||||
_MetaBadge(label: 'Task ID', value: task.id, isMono: true),
|
||||
],
|
||||
),
|
||||
if (description.isNotEmpty) ...[
|
||||
|
|
@ -147,9 +151,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
const SizedBox(height: 16),
|
||||
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(
|
||||
child: messagesAsync.when(
|
||||
data: (messages) => _buildMessages(
|
||||
|
|
@ -157,9 +172,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
messages,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load messages: $error')),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Text('Failed to load messages: $error'),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
|
|
@ -187,13 +204,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_typingLabel(typingState.userIds, profilesAsync),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
_typingLabel(
|
||||
typingState.userIds,
|
||||
profilesAsync,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TypingDots(
|
||||
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,
|
||||
bool canUpdateStatus,
|
||||
) {
|
||||
final chip = Chip(
|
||||
label: Text(task.status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, task.status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, task.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
final chip = StatusPill(
|
||||
label: task.status.toUpperCase(),
|
||||
isEmphasized: task.status != 'queued',
|
||||
);
|
||||
|
||||
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(
|
||||
Profile? profile,
|
||||
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> {
|
||||
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 '../../models/notification_item.dart';
|
||||
import '../../models/office.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/task.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tasks_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
class TasksListScreen extends ConsumerWidget {
|
||||
class TasksListScreen extends ConsumerStatefulWidget {
|
||||
const TasksListScreen({super.key});
|
||||
|
||||
@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 ticketsAsync = ref.watch(ticketsProvider);
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
final profileAsync = ref.watch(currentProfileProvider);
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
|
||||
final canCreate = profileAsync.maybeWhen(
|
||||
data: (profile) =>
|
||||
|
|
@ -32,75 +63,238 @@ class TasksListScreen extends ConsumerWidget {
|
|||
orElse: () => false,
|
||||
);
|
||||
|
||||
final ticketById = {
|
||||
for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket,
|
||||
final ticketById = <String, Ticket>{
|
||||
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
||||
ticket.id: ticket,
|
||||
};
|
||||
final officeById = {
|
||||
for (final office in officesAsync.valueOrNull ?? []) office.id: office,
|
||||
final officeById = <String, 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(
|
||||
body: ResponsiveBody(
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: tasksAsync.when(
|
||||
data: (tasks) {
|
||||
if (tasks.isEmpty) {
|
||||
return const Center(child: Text('No tasks yet.'));
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||
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: [
|
||||
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,
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _subjectController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: tasks.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final task = tasks[index];
|
||||
final ticketId = task.ticketId;
|
||||
final ticket = ticketId == null
|
||||
),
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: DropdownButtonFormField<String?>(
|
||||
isExpanded: true,
|
||||
key: ValueKey(_selectedAssigneeId),
|
||||
initialValue: _selectedAssigneeId,
|
||||
items: staffOptions,
|
||||
onChanged: (value) =>
|
||||
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
|
||||
: 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 officeName = officeId == null
|
||||
? 'Unassigned office'
|
||||
: (officeById[officeId]?.name ?? officeId);
|
||||
final assigned = _assignedAgent(profileById, task.creatorId);
|
||||
final subtitle = _buildSubtitle(officeName, task.status);
|
||||
final hasMention = _hasTaskMention(
|
||||
notificationsAsync,
|
||||
task,
|
||||
);
|
||||
final typingChannelId = task.id;
|
||||
final hasMention = _hasTaskMention(notificationsAsync, task);
|
||||
final typingState = ref.watch(
|
||||
typingIndicatorProvider(typingChannelId),
|
||||
typingIndicatorProvider(task.id),
|
||||
);
|
||||
final showTyping = typingState.userIds.isNotEmpty;
|
||||
|
||||
return ListTile(
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: _buildQueueBadge(context, task),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: Text(
|
||||
task.title.isNotEmpty
|
||||
? task.title
|
||||
: (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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusChip(context, task.status),
|
||||
_StatusBadge(status: task.status),
|
||||
if (showTyping) ...[
|
||||
const SizedBox(width: 6),
|
||||
TypingDots(
|
||||
|
|
@ -120,10 +314,29 @@ class TasksListScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
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')),
|
||||
),
|
||||
),
|
||||
floatingActionButton: canCreate
|
||||
? FloatingActionButton.extended(
|
||||
if (canCreate)
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _showCreateTaskDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Task'),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -287,33 +506,265 @@ class TasksListScreen extends ConsumerWidget {
|
|||
final statusLabel = status.toUpperCase();
|
||||
return '$officeName · $statusLabel';
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String status) {
|
||||
return Chip(
|
||||
label: Text(status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, status),
|
||||
List<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
|
||||
final items = profiles ?? const <Profile>[];
|
||||
final sorted = [...items]
|
||||
..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b)));
|
||||
return [
|
||||
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,
|
||||
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,
|
||||
};
|
||||
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return 'Unassigned';
|
||||
}
|
||||
final profile = profileById[userId];
|
||||
if (profile == null) {
|
||||
return userId;
|
||||
}
|
||||
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||
}
|
||||
|
||||
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,
|
||||
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/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/status_pill.dart';
|
||||
import '../../widgets/task_assignment_section.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
|
|
@ -80,12 +83,14 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
: ticket?.respondedAt;
|
||||
|
||||
return ResponsiveBody(
|
||||
child: Column(
|
||||
children: [
|
||||
if (ticket != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Column(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (ticket == null) {
|
||||
return const Center(child: Text('Ticket not found.'));
|
||||
}
|
||||
|
||||
final isWide = constraints.maxWidth >= AppBreakpoints.desktop;
|
||||
final detailsContent = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
|
|
@ -93,9 +98,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
child: Text(
|
||||
ticket.subject,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
|
@ -107,14 +112,22 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_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),
|
||||
|
|
@ -135,17 +148,27 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: 'Open task',
|
||||
onPressed: () =>
|
||||
context.go('/tasks/${taskForTicket.id}'),
|
||||
onPressed: () => context.go('/tasks/${taskForTicket.id}'),
|
||||
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(
|
||||
child: messagesAsync.when(
|
||||
data: (messages) {
|
||||
|
|
@ -173,9 +196,13 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
message.senderId!;
|
||||
final bubbleColor = isMe
|
||||
? Theme.of(context).colorScheme.primaryContainer
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest;
|
||||
: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest;
|
||||
final textColor = isMe
|
||||
? Theme.of(context).colorScheme.onPrimaryContainer
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryContainer
|
||||
: Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
return Align(
|
||||
|
|
@ -192,20 +219,28 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
senderName,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 520,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bubbleColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(16),
|
||||
topRight: const Radius.circular(16),
|
||||
bottomLeft: Radius.circular(isMe ? 16 : 4),
|
||||
bottomRight: Radius.circular(isMe ? 4 : 16),
|
||||
bottomLeft: Radius.circular(
|
||||
isMe ? 16 : 4,
|
||||
),
|
||||
bottomRight: Radius.circular(
|
||||
isMe ? 4 : 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _buildMentionText(
|
||||
|
|
@ -220,9 +255,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) =>
|
||||
Center(child: Text('Failed to load messages: $error')),
|
||||
loading: () =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) => Center(
|
||||
child: Text('Failed to load messages: $error'),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
|
|
@ -250,13 +287,20 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_typingLabel(typingState.userIds, profilesAsync),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
_typingLabel(
|
||||
typingState.userIds,
|
||||
profilesAsync,
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelSmall,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TypingDots(
|
||||
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
|
||||
? (_) => _handleComposerChanged(
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
Supabase.instance.client.auth.currentUser?.id,
|
||||
Supabase
|
||||
.instance
|
||||
.client
|
||||
.auth
|
||||
.currentUser
|
||||
?.id,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
|
|
@ -296,7 +345,12 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
? (_) => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
Supabase.instance.client.auth.currentUser?.id,
|
||||
Supabase
|
||||
.instance
|
||||
.client
|
||||
.auth
|
||||
.currentUser
|
||||
?.id,
|
||||
canSendMessages,
|
||||
)
|
||||
: null,
|
||||
|
|
@ -309,7 +363,12 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
? () => _handleSendMessage(
|
||||
ref,
|
||||
profilesAsync.valueOrNull ?? [],
|
||||
Supabase.instance.client.auth.currentUser?.id,
|
||||
Supabase
|
||||
.instance
|
||||
.client
|
||||
.auth
|
||||
.currentUser
|
||||
?.id,
|
||||
canSendMessages,
|
||||
)
|
||||
: 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,
|
||||
) {
|
||||
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
|
||||
final chip = Chip(
|
||||
label: Text(_statusLabel(ticket.status)),
|
||||
backgroundColor: _statusColor(context, ticket.status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, ticket.status),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
final chip = StatusPill(
|
||||
label: _statusLabel(ticket.status),
|
||||
isEmphasized: ticket.status != 'pending',
|
||||
);
|
||||
|
||||
if (isLocked) {
|
||||
|
|
@ -834,23 +911,39 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||
bool _canAssignStaff(String role) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
class _MetaBadge extends StatelessWidget {
|
||||
const _MetaBadge({required this.label, required this.value, this.isMono});
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.brown.shade900,
|
||||
'promoted' => Colors.blue.shade900,
|
||||
'closed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
};
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,72 +4,236 @@ import 'package:go_router/go_router.dart';
|
|||
|
||||
import '../../models/office.dart';
|
||||
import '../../models/notification_item.dart';
|
||||
import '../../models/profile.dart';
|
||||
import '../../models/ticket.dart';
|
||||
import '../../providers/notifications_provider.dart';
|
||||
import '../../providers/profile_provider.dart';
|
||||
import '../../providers/tickets_provider.dart';
|
||||
import '../../providers/typing_provider.dart';
|
||||
import '../../widgets/mono_text.dart';
|
||||
import '../../widgets/responsive_body.dart';
|
||||
import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
|
||||
class TicketsListScreen extends ConsumerWidget {
|
||||
class TicketsListScreen extends ConsumerStatefulWidget {
|
||||
const TicketsListScreen({super.key});
|
||||
|
||||
@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 officesAsync = ref.watch(officesProvider);
|
||||
final notificationsAsync = ref.watch(notificationsProvider);
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: ResponsiveBody(
|
||||
return Stack(
|
||||
children: [
|
||||
ResponsiveBody(
|
||||
maxWidth: double.infinity,
|
||||
child: ticketsAsync.when(
|
||||
data: (tickets) {
|
||||
if (tickets.isEmpty) {
|
||||
return const Center(child: Text('No tickets yet.'));
|
||||
}
|
||||
final officeById = {
|
||||
for (final office in officesAsync.valueOrNull ?? [])
|
||||
final officeById = <String, 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,
|
||||
};
|
||||
final unreadByTicketId = _unreadByTicketId(notificationsAsync);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
final offices = officesAsync.valueOrNull ?? <Office>[];
|
||||
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: [
|
||||
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,
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TextField(
|
||||
controller: _subjectController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
itemCount: tickets.length,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final ticket = tickets[index];
|
||||
),
|
||||
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 (_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 =
|
||||
officeById[ticket.officeId]?.name ?? ticket.officeId;
|
||||
final assigned = _assignedAgent(
|
||||
profileById,
|
||||
ticket.creatorId,
|
||||
);
|
||||
final hasMention = unreadByTicketId[ticket.id] == true;
|
||||
final typingState = ref.watch(
|
||||
typingIndicatorProvider(ticket.id),
|
||||
);
|
||||
final showTyping = typingState.userIds.isNotEmpty;
|
||||
return ListTile(
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.confirmation_number_outlined),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildStatusChip(context, ticket.status),
|
||||
_StatusBadge(status: ticket.status),
|
||||
if (showTyping) ...[
|
||||
const SizedBox(width: 6),
|
||||
TypingDots(
|
||||
|
|
@ -89,10 +253,29 @@ class TicketsListScreen extends ConsumerWidget {
|
|||
],
|
||||
),
|
||||
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')),
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: SafeArea(
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _showCreateTicketDialog(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('New Ticket'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -236,33 +426,237 @@ class TicketsListScreen extends ConsumerWidget {
|
|||
orElse: () => <String, bool>{},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(BuildContext context, String status) {
|
||||
return Chip(
|
||||
label: Text(status.toUpperCase()),
|
||||
backgroundColor: _statusColor(context, status),
|
||||
labelStyle: TextStyle(
|
||||
color: _statusTextColor(context, status),
|
||||
List<DropdownMenuItem<String?>> _ticketStatusOptions(List<Ticket> tickets) {
|
||||
final statuses = tickets.map((ticket) => ticket.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<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,
|
||||
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,
|
||||
};
|
||||
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
||||
if (userId == null || userId.isEmpty) {
|
||||
return 'Unassigned';
|
||||
}
|
||||
final profile = profileById[userId];
|
||||
if (profile == null) {
|
||||
return userId;
|
||||
}
|
||||
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
||||
}
|
||||
|
||||
Color _statusTextColor(BuildContext context, String status) {
|
||||
return switch (status) {
|
||||
'pending' => Colors.brown.shade900,
|
||||
'promoted' => Colors.blue.shade900,
|
||||
'closed' => Colors.green.shade900,
|
||||
_ => Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
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:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_typography.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData light() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0C4A6E),
|
||||
seedColor: const Color(0xFF334155),
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
|
||||
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(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF6F8FA),
|
||||
scaffoldBackgroundColor: base.colorScheme.surfaceContainerLowest,
|
||||
extensions: [mono],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
foregroundColor: base.colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||
centerTitle: false,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -28,20 +42,26 @@ class AppTheme {
|
|||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: base.colorScheme.surface,
|
||||
elevation: 0.6,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
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(
|
||||
color: base.colorScheme.outlineVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: base.colorScheme.surface,
|
||||
fillColor: base.colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
|
|
@ -72,6 +92,11 @@ class AppTheme {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
),
|
||||
),
|
||||
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
tileHeight: 52,
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||
|
|
@ -79,6 +104,7 @@ class AppTheme {
|
|||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
|
|
@ -88,8 +114,9 @@ class AppTheme {
|
|||
),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
tileColor: base.colorScheme.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -97,21 +124,33 @@ class AppTheme {
|
|||
static ThemeData dark() {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF38BDF8),
|
||||
seedColor: const Color(0xFF334155),
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
);
|
||||
|
||||
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(
|
||||
textTheme: textTheme,
|
||||
scaffoldBackgroundColor: const Color(0xFF0B111A),
|
||||
scaffoldBackgroundColor: base.colorScheme.surface,
|
||||
extensions: [mono],
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
foregroundColor: base.colorScheme.onSurface,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 1,
|
||||
surfaceTintColor: base.colorScheme.surfaceTint,
|
||||
centerTitle: false,
|
||||
titleTextStyle: textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
|
|
@ -119,21 +158,27 @@ class AppTheme {
|
|||
),
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: const Color(0xFF121A24),
|
||||
color: base.colorScheme.surfaceContainer,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
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(
|
||||
color: base.colorScheme.outlineVariant,
|
||||
thickness: 1,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF121A24),
|
||||
fillColor: base.colorScheme.surfaceContainerLow,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: base.colorScheme.outlineVariant),
|
||||
|
|
@ -164,6 +209,11 @@ class AppTheme {
|
|||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
),
|
||||
),
|
||||
navigationDrawerTheme: NavigationDrawerThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
tileHeight: 52,
|
||||
),
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
selectedIconTheme: IconThemeData(color: base.colorScheme.primary),
|
||||
|
|
@ -171,6 +221,7 @@ class AppTheme {
|
|||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
unselectedIconTheme: IconThemeData(color: base.colorScheme.onSurface),
|
||||
indicatorColor: base.colorScheme.secondaryContainer,
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: base.colorScheme.surface,
|
||||
|
|
@ -180,8 +231,9 @@ class AppTheme {
|
|||
),
|
||||
),
|
||||
listTileTheme: ListTileThemeData(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
tileColor: const Color(0xFF121A24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
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/notifications_provider.dart';
|
||||
import '../providers/profile_provider.dart';
|
||||
import 'app_breakpoints.dart';
|
||||
|
||||
class AppScaffold extends ConsumerWidget {
|
||||
const AppScaffold({super.key, required this.child});
|
||||
|
|
@ -31,9 +32,15 @@ class AppScaffold extends ConsumerWidget {
|
|||
final sections = _buildSections(role);
|
||||
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final showRail = !isStandard && width >= 860;
|
||||
final isExtended = !isStandard && width >= 1120;
|
||||
final showDrawer = !isStandard && !showRail;
|
||||
final showRail = width >= AppBreakpoints.tablet;
|
||||
final isExtended = width >= AppBreakpoints.desktop;
|
||||
|
||||
final railItems = _flattenSections(
|
||||
sections,
|
||||
).where((item) => !item.isLogout).toList();
|
||||
final primaryItems = _primaryItemsForRole(role);
|
||||
final mobilePrimary = _mobilePrimaryItems(primaryItems);
|
||||
final overflowItems = _overflowItems(railItems, mobilePrimary);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
|
|
@ -78,48 +85,61 @@ class AppScaffold extends ConsumerWidget {
|
|||
const _NotificationBell(),
|
||||
],
|
||||
),
|
||||
drawer: showDrawer
|
||||
? Drawer(
|
||||
child: AppSideNav(
|
||||
sections: sections,
|
||||
bottomNavigationBar: showRail
|
||||
? null
|
||||
: AppBottomNav(
|
||||
location: location,
|
||||
extended: true,
|
||||
displayName: displayName,
|
||||
onLogout: () => ref.read(authControllerProvider).signOut(),
|
||||
items: _mobileNavItems(mobilePrimary, overflowItems),
|
||||
onShowMore: overflowItems.isEmpty
|
||||
? null
|
||||
: () => _showOverflowSheet(
|
||||
context,
|
||||
overflowItems,
|
||||
() => ref.read(authControllerProvider).signOut(),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: isStandard
|
||||
? AppBottomNav(location: location, items: _standardNavItems())
|
||||
: null,
|
||||
body: Row(
|
||||
),
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final railWidth = showRail ? (isExtended ? 256.0 : 80.0) : 0.0;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
left: railWidth,
|
||||
child: _ShellBackground(child: child),
|
||||
),
|
||||
if (showRail)
|
||||
AppSideNav(
|
||||
sections: sections,
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: railWidth,
|
||||
child: AppNavigationRail(
|
||||
items: railItems,
|
||||
location: location,
|
||||
extended: isExtended,
|
||||
displayName: displayName,
|
||||
onLogout: () => ref.read(authControllerProvider).signOut(),
|
||||
),
|
||||
Expanded(child: _ShellBackground(child: child)),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppSideNav extends StatelessWidget {
|
||||
const AppSideNav({
|
||||
class AppNavigationRail extends StatelessWidget {
|
||||
const AppNavigationRail({
|
||||
super.key,
|
||||
required this.sections,
|
||||
required this.items,
|
||||
required this.location,
|
||||
required this.extended,
|
||||
required this.displayName,
|
||||
required this.onLogout,
|
||||
});
|
||||
|
||||
final List<NavSection> sections;
|
||||
final List<NavItem> items;
|
||||
final String location;
|
||||
final bool extended;
|
||||
final String displayName;
|
||||
|
|
@ -127,9 +147,8 @@ class AppSideNav extends StatelessWidget {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = extended ? 240.0 : 72.0;
|
||||
final currentIndex = _currentIndex(location, items);
|
||||
return Container(
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
|
|
@ -138,70 +157,38 @@ class AppSideNav extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
child: ListView(
|
||||
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),
|
||||
child: NavigationRail(
|
||||
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 {
|
||||
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 List<NavItem> items;
|
||||
final VoidCallback? onShowMore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -209,10 +196,12 @@ class AppBottomNav extends StatelessWidget {
|
|||
return NavigationBar(
|
||||
selectedIndex: index,
|
||||
onDestinationSelected: (value) {
|
||||
final target = items[value].route;
|
||||
if (target.isNotEmpty) {
|
||||
context.go(target);
|
||||
final item = items[value];
|
||||
if (item.isOverflow) {
|
||||
onShowMore?.call();
|
||||
return;
|
||||
}
|
||||
item.onTap(context);
|
||||
},
|
||||
destinations: [
|
||||
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 {
|
||||
const _NotificationBell();
|
||||
|
||||
|
|
@ -325,6 +270,7 @@ class NavItem {
|
|||
required this.icon,
|
||||
this.selectedIcon,
|
||||
this.isLogout = false,
|
||||
this.isOverflow = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
|
|
@ -332,6 +278,7 @@ class NavItem {
|
|||
final IconData icon;
|
||||
final IconData? selectedIcon;
|
||||
final bool isLogout;
|
||||
final bool isOverflow;
|
||||
|
||||
void onTap(BuildContext context, {VoidCallback? onLogout}) {
|
||||
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) {
|
||||
if (route.isEmpty) return false;
|
||||
if (location == route) return true;
|
||||
|
|
@ -466,5 +513,7 @@ bool _isSelected(String location, String route) {
|
|||
|
||||
int _currentIndex(String location, List<NavItem> items) {
|
||||
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 'app_breakpoints.dart';
|
||||
|
||||
class ResponsiveBody extends StatelessWidget {
|
||||
const ResponsiveBody({
|
||||
super.key,
|
||||
|
|
@ -16,13 +18,18 @@ class ResponsiveBody extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.maxWidth;
|
||||
final horizontalPadding = switch (width) {
|
||||
>= 1200 => 96.0,
|
||||
>= 900 => 64.0,
|
||||
>= 600 => 32.0,
|
||||
_ => 16.0,
|
||||
};
|
||||
final height = constraints.hasBoundedHeight
|
||||
? constraints.maxHeight
|
||||
: MediaQuery.sizeOf(context).height;
|
||||
final width = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: maxWidth;
|
||||
final horizontalPadding = AppBreakpoints.horizontalPadding(width);
|
||||
final boxConstraints = BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
minHeight: height,
|
||||
maxHeight: height,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: padding.add(
|
||||
|
|
@ -31,12 +38,8 @@ class ResponsiveBody extends StatelessWidget {
|
|||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: maxWidth),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: constraints.maxHeight,
|
||||
child: child,
|
||||
),
|
||||
constraints: boxConstraints,
|
||||
child: SizedBox(width: width, 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