diff --git a/lib/screens/admin/offices_screen.dart b/lib/screens/admin/offices_screen.dart index 2c7f48a3..1826cc3d 100644 --- a/lib/screens/admin/offices_screen.dart +++ b/lib/screens/admin/offices_screen.dart @@ -5,98 +5,158 @@ 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 createState() => _OfficesScreenState(); +} + +class _OfficesScreenState extends ConsumerState { + 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, - child: !isAdmin - ? const Center(child: Text('Admin access required.')) - : officesAsync.when( - data: (offices) { - 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), - ), - ), - TextButton.icon( - onPressed: () => context.go('/settings/users'), - icon: const Icon(Icons.group), - label: const Text('User access'), - ), - ], + return Stack( + children: [ + ResponsiveBody( + maxWidth: double.infinity, + child: !isAdmin + ? const Center(child: Text('Admin access required.')) + : officesAsync.when( + data: (offices) { + if (offices.isEmpty) { + return const Center(child: Text('No offices found.')); + } + + 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( + items: filteredOffices, + filterHeader: SizedBox( + width: 320, + child: TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search name', + prefixIcon: Icon(Icons.search), + ), ), ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.only(bottom: 24), - itemCount: offices.length, - separatorBuilder: (_, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - final office = offices[index]; - return ListTile( - leading: const Icon(Icons.apartment_outlined), - title: Text(office.name), - trailing: Wrap( - spacing: 8, - children: [ - IconButton( - tooltip: 'Edit', - icon: const Icon(Icons.edit), - onPressed: () => _showOfficeDialog( - context, - ref, - office: office, - ), - ), - IconButton( - tooltip: 'Delete', - icon: const Icon(Icons.delete), - onPressed: () => - _confirmDelete(context, ref, office), - ), - ], - ), - ); - }, + columns: [ + TasQColumn( + header: 'Office ID', + technical: true, + cellBuilder: (context, office) => Text(office.id), ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load offices: $error')), + TasQColumn( + header: 'Office Name', + cellBuilder: (context, office) => Text(office.name), + ), + ], + rowActions: (office) => [ + IconButton( + tooltip: 'Edit', + icon: const Icon(Icons.edit), + onPressed: () => + _showOfficeDialog(context, ref, office: office), + ), + IconButton( + tooltip: 'Delete', + icon: const Icon(Icons.delete), + onPressed: () => _confirmDelete(context, ref, office), + ), + ], + mobileTileBuilder: (context, office, actions) { + return Card( + child: ListTile( + dense: true, + visualDensity: VisualDensity.compact, + leading: const Icon(Icons.apartment_outlined), + title: Text(office.name), + subtitle: MonoText('ID ${office.id}'), + trailing: Wrap(spacing: 8, children: actions), + ), + ); + }, + ); + + 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: () => + context.go('/settings/users'), + icon: const Icon(Icons.group), + label: const Text('User access'), + ), + ), + ], + ), + ), + Expanded(child: listBody), + ], + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load offices: $error')), + ), + ), + 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'), ), - ), - floatingActionButton: isAdmin - ? FloatingActionButton.extended( - onPressed: () => _showOfficeDialog(context, ref), - icon: const Icon(Icons.add), - label: const Text('New Office'), - ) - : null, + ), + ), + ], ); } diff --git a/lib/screens/admin/user_management_screen.dart b/lib/screens/admin/user_management_screen.dart index e7c3dbba..2aee7186 100644 --- a/lib/screens/admin/user_management_screen.dart +++ b/lib/screens/admin/user_management_screen.dart @@ -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 { ]; final _fullNameController = TextEditingController(); + final _searchController = TextEditingController(); String? _selectedUserId; String? _selectedRole; @@ -43,6 +46,7 @@ class _UserManagementScreenState extends ConsumerState { @override void dispose() { _fullNameController.dispose(); + _searchController.dispose(); super.dispose(); } @@ -54,19 +58,17 @@ class _UserManagementScreenState extends ConsumerState { final assignmentsAsync = ref.watch(userOfficesProvider); final messagesAsync = ref.watch(ticketMessagesAllProvider); - return Scaffold( - body: ResponsiveBody( - maxWidth: 1080, - child: !isAdmin - ? const Center(child: Text('Admin access required.')) - : _buildContent( - context, - profilesAsync, - officesAsync, - assignmentsAsync, - messagesAsync, - ), - ), + return ResponsiveBody( + maxWidth: double.infinity, + child: !isAdmin + ? const Center(child: Text('Admin access required.')) + : _buildContent( + context, + profilesAsync, + officesAsync, + assignmentsAsync, + messagesAsync, + ), ); } @@ -114,45 +116,19 @@ class _UserManagementScreenState extends ConsumerState { } } - 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 profiles, - List offices, - List assignments, - Map 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 = {}; for (final assignment in assignments) { @@ -163,140 +139,127 @@ class _UserManagementScreenState extends ConsumerState { ); } - 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, - ), - 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; - 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); - }, - 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(), - ), + final listBody = TasQAdaptiveList( + items: filteredProfiles, + filterHeader: SizedBox( + width: 320, + child: TextField( + controller: _searchController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Search name', + prefixIcon: Icon(Icons.search), ), ), ), + columns: [ + TasQColumn( + header: 'User', + cellBuilder: (context, profile) { + final label = + profile.fullName.isEmpty ? profile.id : profile.fullName; + return Text(label); + }, + ), + TasQColumn( + 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( + header: 'Role', + cellBuilder: (context, profile) => Text(profile.role), + ), + TasQColumn( + header: 'Offices', + cellBuilder: (context, profile) { + final officesAssigned = officeCountByUser[profile.id] ?? 0; + return Text(officesAssigned == 0 ? 'None' : '$officesAssigned'); + }, + ), + TasQColumn( + header: 'Status', + cellBuilder: (context, profile) { + final status = _statusCache[profile.id]; + final hasError = _statusErrors.contains(profile.id); + final isLoading = _statusLoading.contains(profile.id); + final statusLabel = + _userStatusLabel(status, hasError, isLoading); + return _StatusBadge(label: statusLabel); + }, + ), + TasQColumn( + header: 'Last active', + cellBuilder: (context, profile) { + final lastActive = lastActiveByUser[profile.id]; + return Text(_formatLastActiveLabel(lastActive)); + }, + ), + ], + 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 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 _showUserDialog( @@ -344,6 +307,44 @@ class _UserManagementScreenState extends ConsumerState { ); } + 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 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,17 +658,58 @@ class _UserManagementScreenState extends ConsumerState { } } } +} - String _formatLastActive(DateTime? value) { - if (value == null) return 'N/A'; - final now = DateTime.now(); - final diff = now.difference(value); - if (diff.inMinutes < 1) return 'Just now'; - if (diff.inHours < 1) return '${diff.inMinutes}m ago'; - if (diff.inDays < 1) return '${diff.inHours}h ago'; - if (diff.inDays < 7) return '${diff.inDays}d ago'; - final month = value.month.toString().padLeft(2, '0'); - final day = value.day.toString().padLeft(2, '0'); - return '${value.year}-$month-$day'; +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); + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inHours < 1) return '${diff.inMinutes}m ago'; + if (diff.inDays < 1) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + 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, + ), + ), + ); } } diff --git a/lib/screens/dashboard/dashboard_screen.dart b/lib/screens/dashboard/dashboard_screen.dart index ad7cc021..9b6c8661 100644 --- a/lib/screens/dashboard/dashboard_screen.dart +++ b/lib/screens/dashboard/dashboard_screen.dart @@ -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 = [ + 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( diff --git a/lib/screens/notifications/notifications_screen.dart b/lib/screens/notifications/notifications_screen.dart index 31568890..c48ab543 100644 --- a/lib/screens/notifications/notifications_screen.dart +++ b/lib/screens/notifications/notifications_screen.dart @@ -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,40 +74,52 @@ class NotificationsScreen extends ConsumerWidget { final title = _notificationTitle(item.type, actorName); final icon = _notificationIcon(item.type); - return ListTile( - leading: Icon(icon), - title: Text(title), - subtitle: Text(subtitle), - trailing: item.isUnread - ? const Icon( - Icons.circle, - size: 10, - color: Colors.red, - ) - : null, - onTap: () async { - final ticketId = item.ticketId; - final taskId = item.taskId; - if (ticketId != null) { - await ref - .read(notificationsControllerProvider) - .markReadForTicket(ticketId); - } else if (taskId != null) { - await ref - .read(notificationsControllerProvider) - .markReadForTask(taskId); - } else if (item.isUnread) { - await ref - .read(notificationsControllerProvider) - .markRead(item.id); - } - if (!context.mounted) return; - if (taskId != null) { - context.go('/tasks/$taskId'); - } else if (ticketId != null) { - context.go('/tickets/$ticketId'); - } - }, + return Card( + child: ListTile( + leading: Icon(icon), + title: Text(title), + 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, + size: 10, + color: Colors.red, + ) + : null, + onTap: () async { + final ticketId = item.ticketId; + final taskId = item.taskId; + if (ticketId != null) { + await ref + .read(notificationsControllerProvider) + .markReadForTicket(ticketId); + } else if (taskId != null) { + await ref + .read(notificationsControllerProvider) + .markReadForTask(taskId); + } else if (item.isUnread) { + await ref + .read(notificationsControllerProvider) + .markRead(item.id); + } + if (!context.mounted) return; + if (taskId != null) { + context.go('/tasks/$taskId'); + } else if (ticketId != null) { + context.go('/tickets/$ticketId'); + } + }, + ), ); }, ), diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index bac414ee..adfb8f21 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -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,162 +105,205 @@ class _TaskDetailScreenState extends ConsumerState { ); return ResponsiveBody( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.center, - child: Text( - task.title.isNotEmpty ? task.title : 'Task ${task.id}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= AppBreakpoints.desktop; + + final detailsContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.center, + child: Text( + task.title.isNotEmpty ? task.title : 'Task ${task.id}', + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), - const SizedBox(height: 6), - Align( - alignment: Alignment.center, - child: Text( - _createdByLabel(profilesAsync, task, ticket), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _buildStatusChip(context, task, canUpdateStatus), - Text('Office: $officeName'), - ], - ), - if (description.isNotEmpty) ...[ - const SizedBox(height: 12), - Text(description), - ], - const SizedBox(height: 12), - _buildTatSection(task), - const SizedBox(height: 16), - TaskAssignmentSection(taskId: task.id, canAssign: showAssign), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: messagesAsync.when( - data: (messages) => _buildMessages( - context, - messages, - profilesAsync.valueOrNull ?? [], ), - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load messages: $error')), - ), - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + const SizedBox(height: 6), + Align( + alignment: Alignment.center, + child: Text( + _createdByLabel(profilesAsync, task, ticket), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (typingState.userIds.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _typingLabel(typingState.userIds, profilesAsync), - style: Theme.of(context).textTheme.labelSmall, + _buildStatusChip(context, task, canUpdateStatus), + _MetaBadge(label: 'Office', value: officeName), + _MetaBadge(label: 'Task ID', value: task.id, isMono: true), + ], + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text(description), + ], + const SizedBox(height: 12), + _buildTatSection(task), + const SizedBox(height: 16), + TaskAssignmentSection(taskId: task.id, canAssign: showAssign), + ], + ); + + final detailsCard = Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: SingleChildScrollView(child: detailsContent), + ), + ); + + final messagesCard = Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + children: [ + Expanded( + child: messagesAsync.when( + data: (messages) => _buildMessages( + context, + messages, + profilesAsync.valueOrNull ?? [], + ), + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('Failed to load messages: $error'), + ), + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (typingState.userIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _typingLabel( + typingState.userIds, + profilesAsync, + ), + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + const SizedBox(width: 8), + TypingDots( + size: 8, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), + ), ), - const SizedBox(width: 8), - TypingDots( - size: 8, - color: Theme.of(context).colorScheme.primary, + if (_mentionQuery != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildMentionList(profilesAsync), ), - ], - ), + if (!canSendMessages) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + 'Messaging is disabled for completed tasks.', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: const InputDecoration( + hintText: 'Message...', + ), + textInputAction: TextInputAction.send, + enabled: canSendMessages, + onChanged: (_) => _handleComposerChanged( + profilesAsync.valueOrNull ?? [], + ref.read(currentUserIdProvider), + canSendMessages, + typingChannelId, + ), + onSubmitted: (_) => _handleSendMessage( + task, + profilesAsync.valueOrNull ?? [], + ref.read(currentUserIdProvider), + canSendMessages, + typingChannelId, + ), + ), + ), + const SizedBox(width: 12), + IconButton( + tooltip: 'Send', + onPressed: canSendMessages + ? () => _handleSendMessage( + task, + profilesAsync.valueOrNull ?? [], + ref.read(currentUserIdProvider), + canSendMessages, + typingChannelId, + ) + : null, + icon: const Icon(Icons.send), + ), + ], + ), + ], ), ), - if (_mentionQuery != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _buildMentionList(profilesAsync), - ), - if (!canSendMessages) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'Messaging is disabled for completed tasks.', - style: Theme.of(context).textTheme.labelMedium, - ), - ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: const InputDecoration( - hintText: 'Message...', - ), - textInputAction: TextInputAction.send, - enabled: canSendMessages, - onChanged: (_) => _handleComposerChanged( - profilesAsync.valueOrNull ?? [], - ref.read(currentUserIdProvider), - canSendMessages, - typingChannelId, - ), - onSubmitted: (_) => _handleSendMessage( - task, - profilesAsync.valueOrNull ?? [], - ref.read(currentUserIdProvider), - canSendMessages, - typingChannelId, - ), - ), - ), - const SizedBox(width: 12), - IconButton( - tooltip: 'Send', - onPressed: canSendMessages - ? () => _handleSendMessage( - task, - profilesAsync.valueOrNull ?? [], - ref.read(currentUserIdProvider), - canSendMessages, - typingChannelId, - ) - : null, - icon: const Icon(Icons.send), - ), - ], ), ], ), ), - ), - ], + ); + + 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 { 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 { }; } - 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 assignments, @@ -817,6 +841,40 @@ class _TaskDetailScreenState extends ConsumerState { } } +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 on Iterable { T? get firstOrNull => isEmpty ? null : first; } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 45b360e7..43fd4d6a 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -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 createState() => _TasksListScreenState(); +} + +class _TasksListScreenState extends ConsumerState { + 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,113 +63,301 @@ class TasksListScreen extends ConsumerWidget { orElse: () => false, ); - final ticketById = { - for (final ticket in ticketsAsync.valueOrNull ?? []) ticket.id: ticket, + final ticketById = { + for (final ticket in ticketsAsync.valueOrNull ?? []) + ticket.id: ticket, }; - final officeById = { - for (final office in officesAsync.valueOrNull ?? []) office.id: office, + final officeById = { + for (final office in officesAsync.valueOrNull ?? []) + office.id: office, + }; + final profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, }; - return Scaffold( - body: ResponsiveBody( - child: tasksAsync.when( - data: (tasks) { - if (tasks.isEmpty) { - return const Center(child: Text('No tasks yet.')); - } - return Column( - 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, + return Stack( + children: [ + ResponsiveBody( + maxWidth: double.infinity, + child: tasksAsync.when( + data: (tasks) { + if (tasks.isEmpty) { + return const Center(child: Text('No tasks yet.')); + } + final offices = officesAsync.valueOrNull ?? []; + final officeOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...offices.map( + (office) => DropdownMenuItem( + 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: [ + SizedBox( + width: 220, + child: TextField( + controller: _subjectController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Subject', + prefixIcon: Icon(Icons.search), ), ), ), - ), - 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: 200, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, + onChanged: (value) => + setState(() => _selectedOfficeId = value), + decoration: const InputDecoration(labelText: 'Office'), + ), + ), + SizedBox( + width: 220, + child: DropdownButtonFormField( + 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( + 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( + items: filteredTasks, + onRowTap: (task) => context.go('/tasks/${task.id}'), + summaryDashboard: summaryDashboard, + filterHeader: filterHeader, + columns: [ + TasQColumn( + header: 'Task ID', + technical: true, + cellBuilder: (context, task) => Text(task.id), + ), + TasQColumn( + header: 'Subject', + cellBuilder: (context, task) { + final ticket = task.ticketId == null ? null - : ticketById[ticketId]; - final officeId = ticket?.officeId ?? task.officeId; - final officeName = officeId == null - ? 'Unassigned office' - : (officeById[officeId]?.name ?? officeId); - final subtitle = _buildSubtitle(officeName, task.status); - final hasMention = _hasTaskMention( - notificationsAsync, - task, - ); - final typingChannelId = task.id; - final typingState = ref.watch( - typingIndicatorProvider(typingChannelId), - ); - final showTyping = typingState.userIds.isNotEmpty; - - return ListTile( - leading: _buildQueueBadge(context, task), - title: Text( - task.title.isNotEmpty - ? task.title - : (ticket?.subject ?? 'Task ${task.id}'), - ), - subtitle: Text(subtitle), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildStatusChip(context, task.status), - if (showTyping) ...[ - const SizedBox(width: 6), - TypingDots( - size: 6, - color: Theme.of(context).colorScheme.primary, - ), - ], - if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon( - Icons.circle, - size: 10, - color: Colors.red, - ), - ), - ], - ), - onTap: () => context.go('/tasks/${task.id}'), + : ticketById[task.ticketId]; + return Text( + task.title.isNotEmpty + ? task.title + : (ticket?.subject ?? 'Task ${task.id}'), ); }, ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load tasks: $error')), + TasQColumn( + 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( + header: 'Assigned Agent', + cellBuilder: (context, task) => + Text(_assignedAgent(profileById, task.creatorId)), + ), + TasQColumn( + header: 'Status', + cellBuilder: (context, task) => + _StatusBadge(status: task.status), + ), + TasQColumn( + 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 typingState = ref.watch( + typingIndicatorProvider(task.id), + ); + final showTyping = typingState.userIds.isNotEmpty; + + 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: 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: [ + _StatusBadge(status: task.status), + if (showTyping) ...[ + const SizedBox(width: 6), + TypingDots( + size: 6, + color: Theme.of(context).colorScheme.primary, + ), + ], + if (hasMention) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.circle, + size: 10, + color: Colors.red, + ), + ), + ], + ), + 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), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load tasks: $error')), + ), ), - ), - floatingActionButton: canCreate - ? FloatingActionButton.extended( - onPressed: () => _showCreateTaskDialog(context, ref), - icon: const Icon(Icons.add), - label: const Text('New Task'), - ) - : null, + 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'), + ), + ), + ), + ], ); } @@ -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), - fontWeight: FontWeight.w600, +List> _staffOptions(List? profiles) { + final items = profiles ?? const []; + final sorted = [...items] + ..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b))); + return [ + const DropdownMenuItem(value: null, child: Text('All staff')), + ...sorted.map( + (profile) => DropdownMenuItem( + value: profile.id, + child: Text(_profileLabel(profile)), + ), + ), + ]; +} + +String _profileLabel(Profile profile) { + return profile.fullName.isNotEmpty ? profile.fullName : profile.id; +} + +List> _taskStatusOptions(List tasks) { + final statuses = tasks.map((task) => task.status).toSet().toList()..sort(); + return [ + const DropdownMenuItem(value: null, child: Text('All statuses')), + ...statuses.map( + (status) => DropdownMenuItem(value: status, child: Text(status)), + ), + ]; +} + +List _applyTaskFilters( + List tasks, { + required Map 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 _taskStatusCounts(List tasks) { + final counts = {}; + 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 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 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, + ), + ), + ); } } diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index 8356d628..5835f1a0 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -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,248 +83,326 @@ class _TicketDetailScreenState extends ConsumerState { : ticket?.respondedAt; return ResponsiveBody( - child: Column( - children: [ - if (ticket != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + 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( + alignment: Alignment.center, + child: Text( + ticket.subject, + textAlign: TextAlign.center, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + ), + const SizedBox(height: 6), + Align( + alignment: Alignment.center, + child: Text( + _filedByLabel(profilesAsync, ticket), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, children: [ - Align( - alignment: Alignment.center, - child: Text( - ticket.subject, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + _buildStatusChip(context, ref, ticket, canPromote), + _MetaBadge( + label: 'Office', + value: _officeLabel(officesAsync, ticket), + ), + _MetaBadge( + label: 'Ticket ID', + value: ticket.id, + isMono: true, + ), + ], + ), + const SizedBox(height: 12), + Text(ticket.description), + const SizedBox(height: 12), + _buildTatRow(context, ticket, effectiveRespondedAt), + if (taskForTicket != null) ...[ + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TaskAssignmentSection( + taskId: taskForTicket.id, + canAssign: showAssign, + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Open task', + 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), + ), + ); + + final messagesCard = Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + children: [ + Expanded( + child: messagesAsync.when( + data: (messages) { + if (messages.isEmpty) { + return const Center(child: Text('No messages yet.')); + } + final profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + return ListView.builder( + reverse: true, + padding: const EdgeInsets.fromLTRB(0, 16, 0, 72), + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + final currentUserId = + Supabase.instance.client.auth.currentUser?.id; + final isMe = + currentUserId != null && + message.senderId == currentUserId; + final senderName = message.senderId == null + ? 'System' + : profileById[message.senderId]?.fullName ?? + message.senderId!; + final bubbleColor = isMe + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest; + final textColor = isMe + ? Theme.of( + context, + ).colorScheme.onPrimaryContainer + : Theme.of(context).colorScheme.onSurface; + + return Align( + alignment: isMe + ? Alignment.centerRight + : Alignment.centerLeft, + child: Column( + crossAxisAlignment: isMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + if (!isMe) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + senderName, + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + ), + Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + 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, + ), + ), + ), + child: _buildMentionText( + message.content, + textColor, + profilesAsync.valueOrNull ?? [], + ), + ), + ], + ), + ); + }, + ); + }, + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Text('Failed to load messages: $error'), ), ), ), - const SizedBox(height: 6), - Align( - alignment: Alignment.center, - child: Text( - _filedByLabel(profilesAsync, ticket), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 12, - runSpacing: 8, - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - _buildStatusChip(context, ref, ticket, canPromote), - Text('Office: ${_officeLabel(officesAsync, ticket)}'), - ], - ), - const SizedBox(height: 12), - Text(ticket.description), - const SizedBox(height: 12), - _buildTatRow(context, ticket, effectiveRespondedAt), - if (taskForTicket != null) ...[ - const SizedBox(height: 16), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: TaskAssignmentSection( - taskId: taskForTicket.id, - canAssign: showAssign, - ), - ), - const SizedBox(width: 8), - IconButton( - tooltip: 'Open task', - onPressed: () => - context.go('/tasks/${taskForTicket.id}'), - icon: const Icon(Icons.open_in_new), - ), - ], - ), - ], - ], - ), - ), - const Divider(height: 1), - Expanded( - child: messagesAsync.when( - data: (messages) { - if (messages.isEmpty) { - return const Center(child: Text('No messages yet.')); - } - final profileById = { - for (final profile in profilesAsync.valueOrNull ?? []) - profile.id: profile, - }; - return ListView.builder( - reverse: true, - padding: const EdgeInsets.fromLTRB(0, 16, 0, 72), - itemCount: messages.length, - itemBuilder: (context, index) { - final message = messages[index]; - final currentUserId = - Supabase.instance.client.auth.currentUser?.id; - final isMe = - currentUserId != null && - message.senderId == currentUserId; - final senderName = message.senderId == null - ? 'System' - : profileById[message.senderId]?.fullName ?? - message.senderId!; - final bubbleColor = isMe - ? Theme.of(context).colorScheme.primaryContainer - : Theme.of(context).colorScheme.surfaceContainerHighest; - final textColor = isMe - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurface; - - return Align( - alignment: isMe - ? Alignment.centerRight - : Alignment.centerLeft, + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), child: Column( - crossAxisAlignment: isMe - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isMe) + if (typingState.userIds.isNotEmpty) Padding( - padding: const EdgeInsets.only(bottom: 4), + padding: const EdgeInsets.only(bottom: 6), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _typingLabel( + typingState.userIds, + profilesAsync, + ), + style: Theme.of( + context, + ).textTheme.labelSmall, + ), + const SizedBox(width: 8), + TypingDots( + size: 8, + color: Theme.of( + context, + ).colorScheme.primary, + ), + ], + ), + ), + ), + if (_mentionQuery != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _buildMentionList(profilesAsync), + ), + if (!canSendMessages) + Padding( + padding: const EdgeInsets.only(bottom: 8), child: Text( - senderName, - style: Theme.of(context).textTheme.labelSmall, + 'Messaging is disabled for closed tickets.', + style: Theme.of(context).textTheme.labelMedium, ), ), - Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(12), - 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), + Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: const InputDecoration( + hintText: 'Message...', + ), + enabled: canSendMessages, + textInputAction: TextInputAction.send, + onChanged: canSendMessages + ? (_) => _handleComposerChanged( + profilesAsync.valueOrNull ?? [], + Supabase + .instance + .client + .auth + .currentUser + ?.id, + canSendMessages, + ) + : null, + onSubmitted: canSendMessages + ? (_) => _handleSendMessage( + ref, + profilesAsync.valueOrNull ?? [], + Supabase + .instance + .client + .auth + .currentUser + ?.id, + canSendMessages, + ) + : null, + ), ), - ), - child: _buildMentionText( - message.content, - textColor, - profilesAsync.valueOrNull ?? [], - ), + const SizedBox(width: 12), + IconButton( + tooltip: 'Send', + onPressed: canSendMessages + ? () => _handleSendMessage( + ref, + profilesAsync.valueOrNull ?? [], + Supabase + .instance + .client + .auth + .currentUser + ?.id, + canSendMessages, + ) + : null, + icon: const Icon(Icons.send), + ), + ], ), ], ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load messages: $error')), - ), - ), - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.fromLTRB(0, 8, 0, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (typingState.userIds.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _typingLabel(typingState.userIds, profilesAsync), - style: Theme.of(context).textTheme.labelSmall, - ), - const SizedBox(width: 8), - TypingDots( - size: 8, - color: Theme.of(context).colorScheme.primary, - ), - ], - ), - ), ), - if (_mentionQuery != null) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: _buildMentionList(profilesAsync), - ), - if (!canSendMessages) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Text( - 'Messaging is disabled for closed tickets.', - style: Theme.of(context).textTheme.labelMedium, - ), - ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: const InputDecoration( - hintText: 'Message...', - ), - enabled: canSendMessages, - textInputAction: TextInputAction.send, - onChanged: canSendMessages - ? (_) => _handleComposerChanged( - profilesAsync.valueOrNull ?? [], - Supabase.instance.client.auth.currentUser?.id, - canSendMessages, - ) - : null, - onSubmitted: canSendMessages - ? (_) => _handleSendMessage( - ref, - profilesAsync.valueOrNull ?? [], - Supabase.instance.client.auth.currentUser?.id, - canSendMessages, - ) - : null, - ), - ), - const SizedBox(width: 12), - IconButton( - tooltip: 'Send', - onPressed: canSendMessages - ? () => _handleSendMessage( - ref, - profilesAsync.valueOrNull ?? [], - Supabase.instance.client.auth.currentUser?.id, - canSendMessages, - ) - : null, - icon: const Icon(Icons.send), - ), - ], ), ], ), ), - ), - ], + ); + + 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 { 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 { 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), + ], + ), + ); } } diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index e07dd7b1..ed385fb9 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -4,108 +4,298 @@ 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 createState() => _TicketsListScreenState(); +} + +class _TicketsListScreenState extends ConsumerState { + 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( - child: ticketsAsync.when( - data: (tickets) { - if (tickets.isEmpty) { - return const Center(child: Text('No tickets yet.')); - } - final officeById = { - for (final office in officesAsync.valueOrNull ?? []) - office.id: office, - }; - final unreadByTicketId = _unreadByTicketId(notificationsAsync); - return Column( - 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, + 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 ?? []) + office.id: office, + }; + final profileById = { + for (final profile in profilesAsync.valueOrNull ?? []) + profile.id: profile, + }; + final unreadByTicketId = _unreadByTicketId(notificationsAsync); + final offices = officesAsync.valueOrNull ?? []; + final officeOptions = >[ + const DropdownMenuItem( + value: null, + child: Text('All offices'), + ), + ...offices.map( + (office) => DropdownMenuItem( + 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: [ + SizedBox( + width: 220, + child: TextField( + controller: _subjectController, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + labelText: 'Subject', + prefixIcon: Icon(Icons.search), ), ), ), - ), - 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]; - final officeName = - officeById[ticket.officeId]?.name ?? ticket.officeId; - final hasMention = unreadByTicketId[ticket.id] == true; - final typingState = ref.watch( - typingIndicatorProvider(ticket.id), - ); - final showTyping = typingState.userIds.isNotEmpty; - return ListTile( - leading: const Icon(Icons.confirmation_number_outlined), - title: Text(ticket.subject), - subtitle: Text(officeName), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _buildStatusChip(context, ticket.status), - if (showTyping) ...[ - const SizedBox(width: 6), - TypingDots( - size: 6, - color: Theme.of(context).colorScheme.primary, - ), - ], - if (hasMention) - const Padding( - padding: EdgeInsets.only(left: 8), - child: Icon( - Icons.circle, - size: 10, - color: Colors.red, - ), - ), - ], - ), - onTap: () => context.go('/tickets/${ticket.id}'), - ); - }, + SizedBox( + width: 200, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedOfficeId), + initialValue: _selectedOfficeId, + items: officeOptions, + onChanged: (value) => + setState(() => _selectedOfficeId = value), + decoration: const InputDecoration(labelText: 'Office'), + ), ), - ), - ], - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => - Center(child: Text('Failed to load tickets: $error')), + SizedBox( + width: 180, + child: DropdownButtonFormField( + 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( + items: filteredTickets, + onRowTap: (ticket) => context.go('/tickets/${ticket.id}'), + summaryDashboard: summaryDashboard, + filterHeader: filterHeader, + columns: [ + TasQColumn( + header: 'Ticket ID', + technical: true, + cellBuilder: (context, ticket) => Text(ticket.id), + ), + TasQColumn( + header: 'Subject', + cellBuilder: (context, ticket) => Text(ticket.subject), + ), + TasQColumn( + header: 'Office', + cellBuilder: (context, ticket) => Text( + officeById[ticket.officeId]?.name ?? ticket.officeId, + ), + ), + TasQColumn( + header: 'Assigned Agent', + cellBuilder: (context, ticket) => + Text(_assignedAgent(profileById, ticket.creatorId)), + ), + TasQColumn( + header: 'Status', + cellBuilder: (context, ticket) => + _StatusBadge(status: ticket.status), + ), + TasQColumn( + 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 Card( + child: ListTile( + leading: const Icon(Icons.confirmation_number_outlined), + dense: true, + visualDensity: VisualDensity.compact, + title: Text(ticket.subject), + 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: [ + _StatusBadge(status: ticket.status), + if (showTyping) ...[ + const SizedBox(width: 6), + TypingDots( + size: 6, + color: Theme.of(context).colorScheme.primary, + ), + ], + if (hasMention) + const Padding( + padding: EdgeInsets.only(left: 8), + child: Icon( + Icons.circle, + size: 10, + color: Colors.red, + ), + ), + ], + ), + 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), + ], + ); + }, + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => + Center(child: Text('Failed to load tickets: $error')), + ), ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _showCreateTicketDialog(context, ref), - icon: const Icon(Icons.add), - label: const Text('New Ticket'), - ), + 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: () => {}, ); } +} - Widget _buildStatusChip(BuildContext context, String status) { - return Chip( - label: Text(status.toUpperCase()), - backgroundColor: _statusColor(context, status), - labelStyle: TextStyle( - color: _statusTextColor(context, status), - fontWeight: FontWeight.w600, +List> _ticketStatusOptions(List tickets) { + final statuses = tickets.map((ticket) => ticket.status).toSet().toList() + ..sort(); + return [ + const DropdownMenuItem(value: null, child: Text('All statuses')), + ...statuses.map( + (status) => DropdownMenuItem(value: status, child: Text(status)), + ), + ]; +} + +List _applyTicketFilters( + List 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 _statusCounts(List tickets) { + final counts = {}; + 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 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 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, + ), + ), + ); } } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 33fc206a..cd04b3ba 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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), ), ); } diff --git a/lib/theme/app_typography.dart b/lib/theme/app_typography.dart new file mode 100644 index 00000000..abe30f75 --- /dev/null +++ b/lib/theme/app_typography.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +@immutable +class AppMonoText extends ThemeExtension { + 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? 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(); + return ext ?? const AppMonoText(label: TextStyle(), body: TextStyle()); + } +} diff --git a/lib/widgets/app_breakpoints.dart b/lib/widgets/app_breakpoints.dart new file mode 100644 index 00000000..0a534973 --- /dev/null +++ b/lib/widgets/app_breakpoints.dart @@ -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; + } +} diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 8bd440f4..b89f54bb 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -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, - location: location, - extended: true, - displayName: displayName, - onLogout: () => ref.read(authControllerProvider).signOut(), - ), - ) - : null, - bottomNavigationBar: isStandard - ? AppBottomNav(location: location, items: _standardNavItems()) - : null, - body: Row( - children: [ - if (showRail) - AppSideNav( - sections: sections, + bottomNavigationBar: showRail + ? null + : AppBottomNav( location: location, - extended: isExtended, - displayName: displayName, - onLogout: () => ref.read(authControllerProvider).signOut(), + items: _mobileNavItems(mobilePrimary, overflowItems), + onShowMore: overflowItems.isEmpty + ? null + : () => _showOverflowSheet( + context, + overflowItems, + () => ref.read(authControllerProvider).signOut(), + ), ), - Expanded(child: _ShellBackground(child: child)), - ], + 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) + Positioned( + left: 0, + top: 0, + bottom: 0, + width: railWidth, + child: AppNavigationRail( + items: railItems, + location: location, + extended: isExtended, + displayName: displayName, + onLogout: () => ref.read(authControllerProvider).signOut(), + ), + ), + ], + ); + }, ), ); } } -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 sections; + final List 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,59 +157,21 @@ class AppSideNav extends StatelessWidget { ), ), ), - child: ListView( - padding: const EdgeInsets.symmetric(vertical: 12), - children: [ - Padding( - padding: EdgeInsets.symmetric( - horizontal: extended ? 16 : 12, - vertical: 8, + child: NavigationRail( + extended: extended, + 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), ), - child: Row( - children: [ - Image.asset( - 'assets/tasq_ico.png', - width: 28, - height: 28, - fit: BoxFit.contain, - ), - if (extended) ...[ - const SizedBox(width: 12), - Expanded( - child: Text( - displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ], - ), - ), - const SizedBox(height: 4), - for (final section in sections) ...[ - if (section.label != null && extended) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), - child: Text( - section.label!, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - letterSpacing: 0.4, - ), - ), - ), - for (final item in section.items) - _NavTile( - item: item, - selected: _isSelected(location, item.route), - extended: extended, - onLogout: onLogout, - ), - ], ], ), ); @@ -198,10 +179,16 @@ class AppSideNav extends StatelessWidget { } class AppBottomNav extends StatelessWidget { - const AppBottomNav({super.key, required this.location, required this.items}); + const AppBottomNav({ + super.key, + required this.location, + required this.items, + this.onShowMore, + }); final String location; final List 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 _standardNavItems() { ]; } +List _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 _mobileNavItems(List primary, List overflow) { + if (overflow.isEmpty) { + return primary; + } + return [ + ...primary, + NavItem(label: 'More', route: '', icon: Icons.more_horiz, isOverflow: true), + ]; +} + +List _mobilePrimaryItems(List primary) { + if (primary.length <= 4) { + return primary; + } + return primary.take(4).toList(); +} + +List _flattenSections(List sections) { + return [for (final section in sections) ...section.items]; +} + +List _overflowItems(List all, List primary) { + final primaryRoutes = primary.map((item) => item.route).toSet(); + return all + .where( + (item) => + !item.isLogout && + item.route.isNotEmpty && + !primaryRoutes.contains(item.route), + ) + .toList(); +} + +Future _showOverflowSheet( + BuildContext context, + List items, + VoidCallback onLogout, +) async { + await showModalBottomSheet( + 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 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; } diff --git a/lib/widgets/mono_text.dart b/lib/widgets/mono_text.dart new file mode 100644 index 00000000..415c8dd2 --- /dev/null +++ b/lib/widgets/mono_text.dart @@ -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, + ); + } +} diff --git a/lib/widgets/responsive_body.dart b/lib/widgets/responsive_body.dart index 247894db..de4fa26e 100644 --- a/lib/widgets/responsive_body.dart +++ b/lib/widgets/responsive_body.dart @@ -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), ), ), ); diff --git a/lib/widgets/status_pill.dart b/lib/widgets/status_pill.dart new file mode 100644 index 00000000..ea852064 --- /dev/null +++ b/lib/widgets/status_pill.dart @@ -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, + ), + ), + ); + } +} diff --git a/lib/widgets/tasq_adaptive_list.dart b/lib/widgets/tasq_adaptive_list.dart new file mode 100644 index 00000000..7d77ec0e --- /dev/null +++ b/lib/widgets/tasq_adaptive_list.dart @@ -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 { + 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 = + Widget Function(BuildContext context, T item, List actions); + +typedef TasQRowActions = List Function(T item); + +typedef TasQRowTap = void Function(T item); + +class TasQAdaptiveList 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 items; + final List> columns; + final TasQMobileTileBuilder mobileTileBuilder; + final TasQRowActions? rowActions; + final TasQRowTap? 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 []; + 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 []; + return mobileTileBuilder(context, item, actions); + }, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + ); + final summarySection = summaryDashboard == null + ? null + : [ + SizedBox(width: double.infinity, child: summaryDashboard!), + const SizedBox(height: 12), + ]; + final filterSection = filterHeader == null + ? null + : [ + 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( + 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 + : [ + SizedBox(width: contentWidth, child: summaryDashboard!), + const SizedBox(height: 12), + ]; + final filterSection = filterHeader == null + ? null + : [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 extends DataTableSource { + _TasQTableSource({ + required this.context, + required this.items, + required this.columns, + required this.rowActions, + required this.onRowTap, + }); + + final BuildContext context; + final List items; + final List> columns; + final TasQRowActions? rowActions; + final TasQRowTap? onRowTap; + + @override + DataRow? getRow(int index) { + if (index >= items.length) return null; + final item = items[index]; + final cells = []; + + 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( + BuildContext context, + Widget child, + TasQColumn 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); +} diff --git a/test/layout_smoke_test.dart b/test/layout_smoke_test.dart new file mode 100644 index 00000000..3a0e0ff6 --- /dev/null +++ b/test/layout_smoke_test.dart @@ -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 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 userManagementOverrides() { + return [ + currentProfileProvider.overrideWith((ref) => Stream.value(admin)), + profilesProvider.overrideWith((ref) => Stream.value(const [])), + officesProvider.overrideWith((ref) => Stream.value([office])), + userOfficesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + 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 _pumpScreen( + WidgetTester tester, + Widget child, { + required List overrides, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: overrides, + child: MaterialApp(home: Scaffold(body: child)), + ), + ); +} + +Future _setSurfaceSize(WidgetTester tester, Size size) async { + await tester.binding.setSurfaceSize(size); + addTearDown(() async { + await tester.binding.setSurfaceSize(null); + }); +}