Major UI overhaul

This commit is contained in:
Marc Rejohn Castillano 2026-02-10 23:11:45 +08:00
parent f4dea74394
commit 01c6b3537c
17 changed files with 2977 additions and 1219 deletions

View File

@ -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,
),
),
),
],
);
}

View File

@ -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,
),
),
);
}
}

View File

@ -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(

View File

@ -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');
}
},
),
);
},
),

View File

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

View File

@ -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,
),
),
);
}
}

View File

@ -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),
],
),
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -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),
),
);
}

View 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());
}
}

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

View File

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

View 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,
);
}
}

View File

@ -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),
),
),
);

View 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,
),
),
);
}
}

View 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
View 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);
});
}