diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index c958e4c0..f75f6045 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -30,9 +30,9 @@ import '../../utils/app_time.dart'; import '../../utils/snackbar.dart'; import '../../utils/subject_suggestions.dart'; import '../../widgets/app_breakpoints.dart'; +import '../../widgets/office_picker.dart'; import '../../widgets/mono_text.dart'; import '../../widgets/responsive_body.dart'; -import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/sync_pending_badge.dart'; import '../../widgets/task_assignment_section.dart'; @@ -125,6 +125,7 @@ class _TaskDetailScreenState extends ConsumerState bool _actionSaving = false; bool _actionSaved = false; bool _actionProcessing = false; + bool _isUpdatingStatus = false; bool _pauseActionInFlight = false; Timer? _elapsedTicker; DateTime _elapsedNow = AppTime.now(); @@ -458,62 +459,82 @@ class _TaskDetailScreenState extends ConsumerState const _TaskPendingBanner(), const SizedBox(height: 12), ], - Center( - child: Row( - mainAxisSize: MainAxisSize.min, + // ── Hero zone ──────────────────────────────────────────────── + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Text( - task.title.isNotEmpty - ? task.title - : 'Task ${task.taskNumber ?? task.id}', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge - ?.copyWith(fontWeight: FontWeight.w700), - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + task.title.isNotEmpty + ? task.title + : 'Task ${task.taskNumber ?? task.id}', + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ), + SyncPendingBadge( + isPending: isPending, + compact: true, + ), + const SizedBox(width: 4), + Builder( + builder: (ctx) { + final profile = profileAsync.maybeWhen( + data: (p) => p, + orElse: () => null, + ); + final canEdit = + profile != null && + (profile.role == 'admin' || + profile.role == 'dispatcher' || + profile.role == 'it_staff' || + profile.id == task.creatorId); + if (!canEdit) return const SizedBox.shrink(); + return IconButton( + tooltip: 'Edit task', + onPressed: () => + _showEditTaskDialog(ctx, ref, task), + icon: const Icon(Icons.edit), + ); + }, + ), + ], ), - SyncPendingBadge(isPending: isPending, compact: true), - const SizedBox(width: 8), - Builder( - builder: (ctx) { - final profile = profileAsync.maybeWhen( - data: (p) => p, - orElse: () => null, - ); - final canEdit = - profile != null && - (profile.role == 'admin' || - profile.role == 'dispatcher' || - profile.role == 'it_staff' || - profile.id == task.creatorId); - if (!canEdit) return const SizedBox.shrink(); - return IconButton( - tooltip: 'Edit task', - onPressed: () => - _showEditTaskDialog(ctx, ref, task), - icon: const Icon(Icons.edit), - ); - }, + const SizedBox(height: 6), + Text( + _createdByLabel(profilesAsync, task, ticket), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), ), ], ), ), - const SizedBox(height: 6), - Align( - alignment: Alignment.center, - child: Text( - _createdByLabel(profilesAsync, task, ticket), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.labelMedium, - ), - ), + // ── Meta strip ─────────────────────────────────────────────── const SizedBox(height: 12), Row( children: [ Expanded( child: Wrap( - spacing: 12, - runSpacing: 8, + spacing: 8, + runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildStatusChip( @@ -522,8 +543,13 @@ class _TaskDetailScreenState extends ConsumerState canUpdateStatus, hasAssignedItStaff, ), - _MetaBadge(label: 'Office', value: officeName), _MetaBadge( + icon: Icons.business_rounded, + label: 'Office', + value: officeName, + ), + _MetaBadge( + icon: Icons.tag_rounded, label: 'Task #', value: isPending && task.taskNumber == null ? 'Pending' @@ -586,7 +612,7 @@ class _TaskDetailScreenState extends ConsumerState ); } catch (_) {} }, - icon: const Icon(Icons.print), + icon: const Icon(Icons.print_outlined), ), ], ), @@ -622,7 +648,11 @@ class _TaskDetailScreenState extends ConsumerState ], const SizedBox(height: 16), // Collapsible tabbed details: Assignees / Type & Category / Signatories - ExpansionTile( + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: ExpansionTile( title: const Text('Details'), initiallyExpanded: isWide, childrenPadding: const EdgeInsets.symmetric(horizontal: 0), @@ -2647,6 +2677,8 @@ class _TaskDetailScreenState extends ConsumerState ), ], ), + ), + ), ], ); @@ -2683,8 +2715,29 @@ class _TaskDetailScreenState extends ConsumerState labelColor: Theme.of(context).colorScheme.onSurface, indicatorColor: Theme.of(context).colorScheme.primary, tabs: const [ - Tab(text: 'Chat'), - Tab(text: 'Activity'), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chat_bubble_outline_rounded, + size: 15, + ), + SizedBox(width: 6), + Text('Chat'), + ], + ), + ), + Tab( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.timeline_rounded, size: 15), + SizedBox(width: 6), + Text('Activity'), + ], + ), + ), ], ), ), @@ -2789,62 +2842,86 @@ class _TaskDetailScreenState extends ConsumerState ).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, + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: + BorderRadius.circular(28), + ), + padding: const EdgeInsets.only( + left: 16, + right: 4, + top: 2, + bottom: 2, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: + _messageController, + decoration: InputDecoration( + hintText: 'Message...', + border: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets + .symmetric( + vertical: 10, + ), + ), + textInputAction: + TextInputAction.send, + enabled: canSendMessages, + onChanged: (_) => + _handleComposerChanged( + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, ), - canSendMessages, - typingChannelId, - ), - onSubmitted: (_) => - _handleSendMessage( - task, - profilesAsync - .valueOrNull ?? - [], - ref.read( - currentUserIdProvider, + onSubmitted: (_) => + _handleSendMessage( + task, + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, ), - 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), - ), - ], + IconButton( + tooltip: 'Send', + onPressed: canSendMessages + ? () => + _handleSendMessage( + task, + profilesAsync + .valueOrNull ?? + [], + ref.read( + currentUserIdProvider, + ), + canSendMessages, + typingChannelId, + ) + : null, + icon: const Icon( + Icons.send_rounded, + ), + ), + ], + ), ), ], ), @@ -2969,7 +3046,36 @@ class _TaskDetailScreenState extends ConsumerState List profiles, ) { if (messages.isEmpty) { - return const Center(child: Text('No messages yet.')); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline_rounded, + size: 44, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.35), + ), + const SizedBox(height: 12), + Text( + 'No messages yet', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Be the first to send a message', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + ), + ), + ], + ), + ); } final profileById = {for (final profile in profiles) profile.id: profile}; final currentUserId = ref.read(currentUserIdProvider); @@ -3276,34 +3382,55 @@ class _TaskDetailScreenState extends ConsumerState DateTime at, { IconData icon = Icons.circle, }) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + final cs = Theme.of(context).colorScheme; + final theme = Theme.of(context); + return IntrinsicHeight( child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( - width: 28, - child: Center( - child: Icon( - icon, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), - Expanded( + width: 32, child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 4), - Text( - '$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}', - style: Theme.of(context).textTheme.labelSmall, + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: cs.primaryContainer, + shape: BoxShape.circle, + ), + child: Icon(icon, size: 14, color: cs.onPrimaryContainer), + ), + Expanded( + child: Container( + width: 1.5, + margin: const EdgeInsets.symmetric(vertical: 4), + color: cs.outlineVariant, + ), ), ], ), ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text(title, style: theme.textTheme.bodyMedium), + const SizedBox(height: 3), + Text( + '$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}', + style: theme.textTheme.labelSmall?.copyWith( + color: cs.onSurfaceVariant, + ), + ), + ], + ), + ), + ), ], ), ); @@ -3866,7 +3993,9 @@ class _TaskDetailScreenState extends ConsumerState return AlertDialog( shape: dialogShape, title: const Text('Edit Task'), - content: SingleChildScrollView( + content: SizedBox( + width: 600, + child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -4034,22 +4163,10 @@ class _TaskDetailScreenState extends ConsumerState b.name.toLowerCase(), ), ); - return DropdownButtonFormField( - initialValue: selectedOffice, - decoration: const InputDecoration( - labelText: 'Office', - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - for (final o in officesSorted) - DropdownMenuItem( - value: o.id, - child: Text(o.name), - ), - ], + return OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOffice, + allowUnassigned: true, onChanged: saving ? null : (v) => setDialogState( @@ -4068,6 +4185,7 @@ class _TaskDetailScreenState extends ConsumerState ], ), ), + ), actions: [ TextButton( onPressed: saving @@ -4238,9 +4356,9 @@ class _TaskDetailScreenState extends ConsumerState bool canUpdateStatus, bool hasAssignedItStaff, ) { - final chip = StatusPill( - label: task.status.toUpperCase(), - isEmphasized: task.status != 'queued', + final chip = _TaskStatusChip( + status: task.status, + isLoading: _isUpdatingStatus, ); final isTerminal = @@ -4404,9 +4522,7 @@ class _TaskDetailScreenState extends ConsumerState return; } - // Update DB only — Supabase realtime stream will emit the - // updated task list, so explicit invalidation here causes a - // visible loading/refresh and is unnecessary. + if (mounted) setState(() => _isUpdatingStatus = true); try { await ref .read(tasksControllerProvider) @@ -4421,6 +4537,8 @@ class _TaskDetailScreenState extends ConsumerState showErrorSnackBarGlobal(e.toString()); } } + } finally { + if (mounted) setState(() => _isUpdatingStatus = false); } }, itemBuilder: (context) => statusOptions @@ -4684,30 +4802,106 @@ class _TaskDetailScreenState extends ConsumerState // PDF preview/building moved to `task_pdf.dart`. } -class _MetaBadge extends StatelessWidget { - const _MetaBadge({required this.label, required this.value, this.isMono}); - - final String label; - final String value; - final bool? isMono; +class _TaskStatusChip extends StatelessWidget { + const _TaskStatusChip({required this.status, this.isLoading = false}); + final String status; + final bool isLoading; @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; + final cs = Theme.of(context).colorScheme; + final (icon, bg, fg) = switch (status) { + 'queued' => ( + Icons.inbox_rounded, + cs.secondaryContainer, + cs.onSecondaryContainer, + ), + 'in_progress' => ( + Icons.play_arrow_rounded, + cs.primaryContainer, + cs.onPrimaryContainer, + ), + 'completed' => ( + Icons.check_circle_rounded, + cs.tertiaryContainer, + cs.onTertiaryContainer, + ), + 'cancelled' => ( + Icons.cancel_rounded, + cs.errorContainer, + cs.onErrorContainer, + ), + _ => ( + Icons.help_outline_rounded, + cs.surfaceContainerHighest, + cs.onSurface, + ), + }; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: border), + color: bg, + borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(label, style: textStyle), - const SizedBox(width: 6), + if (isLoading) + SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator(strokeWidth: 2, color: fg), + ) + else + Icon(icon, size: 13, color: fg), + const SizedBox(width: 5), + Text( + status.toUpperCase().replaceAll('_', ' '), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } +} + +class _MetaBadge extends StatelessWidget { + const _MetaBadge({ + required this.label, + required this.value, + this.isMono, + this.icon, + }); + + final String label; + final String value; + final bool? isMono; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.labelSmall; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: cs.onSurfaceVariant), + const SizedBox(width: 5), + ], + Text(label, style: textStyle?.copyWith(color: cs.onSurfaceVariant)), + const SizedBox(width: 5), if (isMono == true) MonoText(value, style: textStyle) else diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index b33d485c..bb7d87ac 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart'; import '../../models/notification_item.dart'; import '../../models/office.dart'; +import '../../widgets/office_picker.dart'; import '../../models/profile.dart'; import '../../models/task.dart'; import '../../models/task_assignment.dart'; @@ -961,31 +962,15 @@ class _TasksListScreenState extends ConsumerState b.name.toLowerCase(), ), ); - if (selectedOfficeId == null || - !officesSorted.any((o) => o.id == selectedOfficeId)) { - selectedOfficeId = officesSorted.first.id; - } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - DropdownButtonFormField( - initialValue: selectedOfficeId, - decoration: const InputDecoration( - labelText: 'Office', - ), - items: officesSorted - .map( - (office) => DropdownMenuItem( - value: office.id, - child: Text(office.name), - ), - ) - .toList(), + OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOfficeId, onChanged: saving ? null - : (value) => setState( - () => selectedOfficeId = value, - ), + : (id) => setState(() => selectedOfficeId = id), ), const SizedBox(height: 12), // optional request metadata inputs diff --git a/lib/screens/tickets/ticket_detail_screen.dart b/lib/screens/tickets/ticket_detail_screen.dart index e91e94b7..75450226 100644 --- a/lib/screens/tickets/ticket_detail_screen.dart +++ b/lib/screens/tickets/ticket_detail_screen.dart @@ -16,9 +16,9 @@ import '../../providers/tickets_provider.dart'; import '../../providers/typing_provider.dart'; import '../../utils/snackbar.dart'; import '../../widgets/app_breakpoints.dart'; +import '../../widgets/office_picker.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'; import '../../theme/app_surfaces.dart'; @@ -38,6 +38,7 @@ class _TicketDetailScreenState extends ConsumerState { String? _mentionQuery; int? _mentionStart; List _mentionResults = []; + bool _isUpdatingStatus = false; @override void initState() { @@ -99,66 +100,83 @@ class _TicketDetailScreenState extends ConsumerState { final detailsContent = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Center( - child: Row( - mainAxisSize: MainAxisSize.min, + // ── Hero zone ────────────────────────────────────────────── + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .primaryContainer + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 8, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Flexible( - child: Text( - ticket.subject, - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + ticket.subject, + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), ), - ), + Builder( + builder: (ctx) { + final profile = currentProfileAsync.maybeWhen( + data: (p) => p, + orElse: () => null, + ); + final canEdit = + profile != null && + (profile.role == 'admin' || + profile.role == 'programmer' || + profile.role == 'dispatcher' || + profile.role == 'it_staff' || + profile.id == ticket.creatorId); + if (!canEdit) return const SizedBox.shrink(); + return IconButton( + tooltip: 'Edit ticket', + onPressed: () => + _showEditTicketDialog(ctx, ref, ticket), + icon: const Icon(Icons.edit), + ); + }, + ), + ], ), - const SizedBox(width: 8), - Builder( - builder: (ctx) { - final profile = currentProfileAsync.maybeWhen( - data: (p) => p, - orElse: () => null, - ); - final canEdit = - profile != null && - (profile.role == 'admin' || - profile.role == 'programmer' || - profile.role == 'dispatcher' || - profile.role == 'it_staff' || - profile.id == ticket.creatorId); - if (!canEdit) return const SizedBox.shrink(); - return IconButton( - tooltip: 'Edit ticket', - onPressed: () => - _showEditTicketDialog(ctx, ref, ticket), - icon: const Icon(Icons.edit), - ); - }, + const SizedBox(height: 6), + Text( + _filedByLabel(profilesAsync, ticket), + style: Theme.of(context).textTheme.labelMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ), ), ], ), ), - const SizedBox(height: 6), - Align( - alignment: Alignment.center, - child: Text( - _filedByLabel(profilesAsync, ticket), - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.bodySmall, - ), - ), + // ── Meta strip ───────────────────────────────────────────── const SizedBox(height: 12), Wrap( - spacing: 12, - runSpacing: 8, + spacing: 8, + runSpacing: 6, crossAxisAlignment: WrapCrossAlignment.center, children: [ _buildStatusChip(context, ref, ticket, canPromote), _MetaBadge( + icon: Icons.business_rounded, label: 'Office', value: _officeLabel(officesAsync, ticket), ), _MetaBadge( + icon: Icons.confirmation_number_rounded, label: 'Ticket ID', value: ticket.id, isMono: true, @@ -167,37 +185,51 @@ class _TicketDetailScreenState extends ConsumerState { ), const SizedBox(height: 12), // collapse the rest of the details so tall chat areas won't push off-screen - ExpansionTile( - key: const Key('ticket-details-expansion'), - title: const Text('Details'), - initiallyExpanded: true, - childrenPadding: const EdgeInsets.only(top: 8), - children: [ - 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, - ), + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Material( + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: ExpansionTile( + key: const Key('ticket-details-expansion'), + title: const Text('Details'), + initiallyExpanded: true, + childrenPadding: const EdgeInsets.only(top: 8), + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 SizedBox(width: 8), - IconButton( - tooltip: 'Open task', - onPressed: () => - context.go('/tasks/${taskForTicket.id}'), - icon: const Icon(Icons.open_in_new), - ), - ], - ), - ], - ], + ), + ], + ), + ), ), ], ); @@ -232,7 +264,46 @@ class _TicketDetailScreenState extends ConsumerState { child: messagesAsync.when( data: (messages) { if (messages.isEmpty) { - return const Center(child: Text('No messages yet.')); + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline_rounded, + size: 44, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.35), + ), + const SizedBox(height: 12), + Text( + 'No messages yet', + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + ), + const SizedBox(height: 4), + Text( + 'Be the first to send a message', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.6), + ), + ), + ], + ), + ); } final profileById = { for (final profile in profilesAsync.valueOrNull ?? []) @@ -391,47 +462,66 @@ class _TicketDetailScreenState extends ConsumerState { style: Theme.of(context).textTheme.labelMedium, ), ), - Row( - children: [ - Expanded( - child: TextField( - controller: _messageController, - decoration: const InputDecoration( - hintText: 'Message...', + Container( + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + borderRadius: BorderRadius.circular(28), + ), + padding: const EdgeInsets.only( + left: 16, + right: 4, + top: 2, + bottom: 2, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _messageController, + decoration: InputDecoration( + hintText: 'Message...', + border: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric( + vertical: 10, + ), + ), + enabled: canSendMessages, + textInputAction: TextInputAction.send, + onChanged: canSendMessages + ? (_) => _handleComposerChanged( + profilesAsync.valueOrNull ?? [], + currentUserId, + canSendMessages, + ) + : null, + onSubmitted: canSendMessages + ? (_) => _handleSendMessage( + ref, + profilesAsync.valueOrNull ?? [], + currentUserId, + canSendMessages, + ) + : null, ), - enabled: canSendMessages, - textInputAction: TextInputAction.send, - onChanged: canSendMessages - ? (_) => _handleComposerChanged( - profilesAsync.valueOrNull ?? [], - currentUserId, - canSendMessages, - ) - : null, - onSubmitted: canSendMessages - ? (_) => _handleSendMessage( + ), + IconButton( + tooltip: 'Send', + onPressed: canSendMessages + ? () => _handleSendMessage( ref, profilesAsync.valueOrNull ?? [], currentUserId, canSendMessages, ) : null, + icon: const Icon(Icons.send_rounded), ), - ), - const SizedBox(width: 12), - IconButton( - tooltip: 'Send', - onPressed: canSendMessages - ? () => _handleSendMessage( - ref, - profilesAsync.valueOrNull ?? [], - currentUserId, - canSendMessages, - ) - : null, - icon: const Icon(Icons.send), - ), - ], + ], + ), ), ], ), @@ -888,19 +978,172 @@ class _TicketDetailScreenState extends ConsumerState { final descCtrl = TextEditingController(text: ticket.description); String? selectedOffice = ticket.officeId; - await m3ShowDialog( - context: context, - builder: (dialogContext) { - var saving = false; - return StatefulBuilder( - builder: (dialogBuilderContext, setDialogState) { - return AlertDialog( - shape: dialogShape, - title: const Text('Edit Ticket'), - content: SingleChildScrollView( + final isWide = + MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet; + + if (isWide) { + await m3ShowDialog( + context: context, + builder: (dialogContext) { + var saving = false; + return StatefulBuilder( + builder: (dialogBuilderContext, setDialogState) { + return AlertDialog( + shape: dialogShape, + title: const Text('Edit Ticket'), + content: SizedBox( + width: 600, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: subjectCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Subject', + ), + ), + const SizedBox(height: 8), + TextField( + controller: descCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Description', + ), + maxLines: 4, + ), + const SizedBox(height: 8), + officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOffice, + allowUnassigned: true, + onChanged: saving + ? null + : (v) => + setDialogState(() => selectedOffice = v), + ); + }, + loading: () => const SizedBox.shrink(), + error: (error, stack) => const SizedBox.shrink(), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving + ? null + : () async { + final subject = subjectCtrl.text.trim(); + final desc = descCtrl.text.trim(); + setDialogState(() => saving = true); + try { + await ref + .read(ticketsControllerProvider) + .updateTicket( + ticketId: ticket.id, + subject: subject.isEmpty ? null : subject, + description: + desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + ref.invalidate(ticketsProvider); + ref.invalidate(ticketByIdProvider(ticket.id)); + if (!dialogContext.mounted || + !screenContext.mounted) { + return; + } + Navigator.of(dialogContext).pop(); + showSuccessSnackBar( + screenContext, + 'Ticket updated', + ); + } catch (e) { + if (!screenContext.mounted) return; + if (isOfflineSaveError(e)) { + if (dialogContext.mounted) { + Navigator.of(dialogContext).pop(); + } + showSuccessSnackBar( + screenContext, + 'Ticket update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar( + screenContext, + 'Failed to update ticket: $e', + ); + } + } finally { + if (dialogContext.mounted) { + setDialogState(() => saving = false); + } + } + }, + child: saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + } else { + await m3ShowBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (sheetContext) { + var saving = false; + return StatefulBuilder( + builder: (ctx, setDialogState) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 24, + bottom: MediaQuery.viewInsetsOf(ctx).bottom + 24, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ + Row( + children: [ + Text( + 'Edit Ticket', + style: Theme.of(ctx).textTheme.titleLarge, + ), + const Spacer(), + IconButton( + icon: const Icon(Icons.close), + onPressed: + saving ? null : () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 16), TextField( controller: subjectCtrl, enabled: !saving, @@ -924,103 +1167,104 @@ class _TicketDetailScreenState extends ConsumerState { b.name.toLowerCase(), ), ); - return DropdownButtonFormField( - initialValue: selectedOffice, - decoration: const InputDecoration( - labelText: 'Office', - ), - items: [ - const DropdownMenuItem( - value: null, - child: Text('Unassigned'), - ), - for (final o in officesSorted) - DropdownMenuItem( - value: o.id, - child: Text(o.name), - ), - ], + return OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOffice, + allowUnassigned: true, onChanged: saving ? null - : (v) => setDialogState(() => selectedOffice = v), + : (v) => + setDialogState(() => selectedOffice = v), ); }, loading: () => const SizedBox.shrink(), error: (error, stack) => const SizedBox.shrink(), ), + const SizedBox(height: 16), + OverflowBar( + alignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: saving + ? null + : () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: saving + ? null + : () async { + final subject = subjectCtrl.text.trim(); + final desc = descCtrl.text.trim(); + setDialogState(() => saving = true); + try { + await ref + .read(ticketsControllerProvider) + .updateTicket( + ticketId: ticket.id, + subject: subject.isEmpty + ? null + : subject, + description: desc.isEmpty + ? null + : desc, + officeId: selectedOffice, + ); + ref.invalidate(ticketsProvider); + ref.invalidate( + ticketByIdProvider(ticket.id), + ); + if (!ctx.mounted || + !screenContext.mounted) { + return; + } + Navigator.of(ctx).pop(); + showSuccessSnackBar( + screenContext, + 'Ticket updated', + ); + } catch (e) { + if (!screenContext.mounted) return; + if (isOfflineSaveError(e)) { + if (ctx.mounted) { + Navigator.of(ctx).pop(); + } + showSuccessSnackBar( + screenContext, + 'Ticket update saved offline — will sync when connected.', + ); + } else { + showErrorSnackBar( + screenContext, + 'Failed to update ticket: $e', + ); + } + } finally { + if (ctx.mounted) { + setDialogState(() => saving = false); + } + } + }, + child: saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Text('Save'), + ), + ], + ), ], ), - ), - actions: [ - TextButton( - onPressed: saving - ? null - : () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: saving - ? null - : () async { - final subject = subjectCtrl.text.trim(); - final desc = descCtrl.text.trim(); - setDialogState(() => saving = true); - try { - await ref - .read(ticketsControllerProvider) - .updateTicket( - ticketId: ticket.id, - subject: subject.isEmpty ? null : subject, - description: desc.isEmpty ? null : desc, - officeId: selectedOffice, - ); - ref.invalidate(ticketsProvider); - ref.invalidate(ticketByIdProvider(ticket.id)); - if (!dialogContext.mounted || - !screenContext.mounted) { - return; - } - Navigator.of(dialogContext).pop(); - showSuccessSnackBar( - screenContext, - 'Ticket updated', - ); - } catch (e) { - if (!screenContext.mounted) return; - if (isOfflineSaveError(e)) { - if (dialogContext.mounted) { - Navigator.of(dialogContext).pop(); - } - showSuccessSnackBar( - screenContext, - 'Ticket update saved offline — will sync when connected.', - ); - } else { - showErrorSnackBar( - screenContext, - 'Failed to update ticket: $e', - ); - } - } finally { - if (dialogContext.mounted) { - setDialogState(() => saving = false); - } - } - }, - child: saving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), - ), - ], - ); - }, - ); - }, - ); + ); + }, + ); + }, + ); + } } Widget _timelineRow(String label, DateTime? value) { @@ -1049,9 +1293,9 @@ class _TicketDetailScreenState extends ConsumerState { bool canPromote, ) { final isLocked = ticket.status == 'promoted' || ticket.status == 'closed'; - final chip = StatusPill( - label: _statusLabel(ticket.status), - isEmphasized: ticket.status != 'pending', + final chip = _TicketStatusChip( + status: ticket.status, + isLoading: _isUpdatingStatus, ); if (isLocked) { @@ -1064,10 +1308,16 @@ class _TicketDetailScreenState extends ConsumerState { return PopupMenuButton( onSelected: (value) async { - // Rely on the realtime stream to propagate the status change. - await ref - .read(ticketsControllerProvider) - .updateTicketStatus(ticketId: ticket.id, status: value); + if (mounted) setState(() => _isUpdatingStatus = true); + try { + await ref + .read(ticketsControllerProvider) + .updateTicketStatus(ticketId: ticket.id, status: value); + } catch (e) { + if (mounted) showErrorSnackBarGlobal(e.toString()); + } finally { + if (mounted) setState(() => _isUpdatingStatus = false); + } }, itemBuilder: (context) => availableStatuses .map( @@ -1081,9 +1331,6 @@ class _TicketDetailScreenState extends ConsumerState { ); } - String _statusLabel(String status) { - return status.toUpperCase(); - } String _statusMenuLabel(String status) { return switch (status) { @@ -1103,32 +1350,101 @@ class _TicketDetailScreenState 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; +class _TicketStatusChip extends StatelessWidget { + const _TicketStatusChip({required this.status, this.isLoading = false}); + final String status; + final bool isLoading; @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; + final cs = Theme.of(context).colorScheme; + final (icon, bg, fg) = switch (status) { + 'pending' => ( + Icons.hourglass_empty_rounded, + cs.secondaryContainer, + cs.onSecondaryContainer, + ), + 'promoted' => ( + Icons.trending_up_rounded, + cs.primaryContainer, + cs.onPrimaryContainer, + ), + 'closed' => ( + Icons.lock_rounded, + cs.tertiaryContainer, + cs.onTertiaryContainer, + ), + _ => ( + Icons.help_outline_rounded, + cs.surfaceContainerHighest, + cs.onSurface, + ), + }; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: background, - borderRadius: BorderRadius.circular( - AppSurfaces.of(context).compactCardRadius, - ), - border: Border.all(color: border), + color: bg, + borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(label, style: textStyle), - const SizedBox(width: 6), + if (isLoading) + SizedBox( + width: 13, + height: 13, + child: CircularProgressIndicator(strokeWidth: 2, color: fg), + ) + else + Icon(icon, size: 13, color: fg), + const SizedBox(width: 5), + Text( + status.toUpperCase(), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + ], + ), + ); + } +} + +class _MetaBadge extends StatelessWidget { + const _MetaBadge({ + required this.label, + required this.value, + this.isMono, + this.icon, + }); + + final String label; + final String value; + final bool? isMono; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final textStyle = Theme.of(context).textTheme.labelSmall; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: cs.surfaceContainerLow, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: cs.outlineVariant), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 12, color: cs.onSurfaceVariant), + const SizedBox(width: 5), + ], + Text(label, style: textStyle?.copyWith(color: cs.onSurfaceVariant)), + const SizedBox(width: 5), if (isMono == true) MonoText(value, style: textStyle) else diff --git a/lib/screens/tickets/tickets_list_screen.dart b/lib/screens/tickets/tickets_list_screen.dart index 5e461873..20cb35ec 100644 --- a/lib/screens/tickets/tickets_list_screen.dart +++ b/lib/screens/tickets/tickets_list_screen.dart @@ -5,6 +5,7 @@ import 'package:tasq/utils/app_time.dart'; import 'package:go_router/go_router.dart'; import '../../models/office.dart'; +import '../../widgets/office_picker.dart'; import '../../models/notification_item.dart'; import '../../models/profile.dart'; import '../../models/ticket.dart'; @@ -440,7 +441,7 @@ class _TicketsListScreenState extends ConsumerState { ) async { final subjectController = TextEditingController(); final descriptionController = TextEditingController(); - Office? selectedOffice; + String? selectedOfficeId; final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; var saving = false; @@ -471,18 +472,10 @@ class _TicketsListScreenState extends ConsumerState { if (offices.isEmpty) return const Text('No offices assigned.'); final officesSorted = List.from(offices) ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); - if (selectedOffice == null || - !officesSorted.any((o) => o.id == selectedOffice!.id)) { - selectedOffice = officesSorted.first; - } - return DropdownButtonFormField( - key: ValueKey(selectedOffice?.id), - initialValue: selectedOffice, - items: officesSorted - .map((o) => DropdownMenuItem(value: o, child: Text(o.name))) - .toList(), - onChanged: saving ? null : (v) => setState(() => selectedOffice = v), - decoration: const InputDecoration(labelText: 'Office'), + return OfficeSelectorField( + offices: officesSorted, + selectedOfficeId: selectedOfficeId, + onChanged: saving ? null : (id) => setState(() => selectedOfficeId = id), ); }, loading: () => const LinearProgressIndicator(), @@ -506,7 +499,7 @@ class _TicketsListScreenState extends ConsumerState { : () async { final subject = subjectController.text.trim(); final description = descriptionController.text.trim(); - if (subject.isEmpty || description.isEmpty || selectedOffice == null) { + if (subject.isEmpty || description.isEmpty || selectedOfficeId == null) { showWarningSnackBar(closeCtx, 'Fill out all fields.'); return; } @@ -517,7 +510,7 @@ class _TicketsListScreenState extends ConsumerState { .createTicket( subject: subject, description: description, - officeId: selectedOffice!.id, + officeId: selectedOfficeId!, ); if (pendingTicket != null) { final current = ref.read(offlinePendingTicketsProvider); diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 756d08ae..5b694126 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -89,6 +89,10 @@ class AppTheme { scrolledUnderElevation: 2, surfaceTintColor: cs.surfaceTint, centerTitle: false, + toolbarHeight: 64, + titleSpacing: 16.0, + iconTheme: IconThemeData(color: cs.onSurfaceVariant, size: 24), + actionsIconTheme: IconThemeData(color: cs.onSurfaceVariant, size: 24), titleTextStyle: textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w700, letterSpacing: 0.2, diff --git a/lib/widgets/app_shell.dart b/lib/widgets/app_shell.dart index 63d01724..fcd546cc 100644 --- a/lib/widgets/app_shell.dart +++ b/lib/widgets/app_shell.dart @@ -58,69 +58,29 @@ class AppScaffold extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Row( - children: [ - Image.asset('assets/tasq_ico.png', width: 28, height: 28), - const SizedBox(width: 8), - Text('TasQ'), - ], + title: _AppBarTitle( + location: location, + showRail: showRail, + isExtended: isExtended, ), actions: [ - if (isStandard) - PopupMenuButton( - tooltip: 'Account', - onSelected: (value) { - if (value == 0) { - context.go('/profile'); - } else if (value == 1) { - ref.read(authControllerProvider).signOut(); - } - }, - itemBuilder: (context) => const [ - PopupMenuItem(value: 0, child: Text('My profile')), - PopupMenuItem(value: 1, child: Text('Sign out')), - ], - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - ProfileAvatar( - fullName: displayName, - avatarUrl: avatarUrl, - radius: 16, - heroTag: 'profile-avatar', - ), - const SizedBox(width: 8), - Text(displayName), - const SizedBox(width: 4), - const Icon(Icons.expand_more), - ], - ), - ), - ) - else - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Profile', - onPressed: () => context.go('/profile'), - icon: ProfileAvatar( - fullName: displayName, - avatarUrl: avatarUrl, - radius: 16, - heroTag: 'profile-avatar', - ), - ), - IconButton( - tooltip: 'Sign out', - onPressed: () => ref.read(authControllerProvider).signOut(), - icon: const Icon(Icons.logout), - ), - ], - ), + _ProfileMenuAnchor( + isStandard: isStandard, + displayName: displayName, + avatarUrl: avatarUrl, + onProfile: () => context.go('/profile'), + onSignOut: () => ref.read(authControllerProvider).signOut(), + ), const _NotificationBell(), ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Divider( + height: 1, + thickness: 1, + color: Theme.of(context).colorScheme.outlineVariant.withValues(alpha: 0.5), + ), + ), ), bottomNavigationBar: showRail ? null @@ -279,43 +239,35 @@ class _NotificationBell extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final unreadCount = ref.watch(unreadNotificationsCountProvider); - final cs = Theme.of(context).colorScheme; - return IconButton( - tooltip: 'Notifications', - onPressed: () => context.go('/notifications'), - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.notifications_outlined), - Positioned( - right: -3, - top: -3, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - switchInCurve: Curves.easeOutBack, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => ScaleTransition( - scale: animation, - child: child, - ), - child: unreadCount > 0 - ? Container( - key: const ValueKey('badge'), - width: 10, - height: 10, - decoration: BoxDecoration( - color: cs.error, - shape: BoxShape.circle, - border: Border.all( - color: cs.surface, - width: 1.5, - ), - ), - ) - : const SizedBox.shrink(key: ValueKey('no-badge')), + final String? badgeLabel = unreadCount == 0 + ? null + : unreadCount > 99 + ? '99+' + : unreadCount.toString(); + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: IconButton( + key: notificationBellKey, + tooltip: 'Notifications', + onPressed: () => context.go('/notifications'), + icon: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: Badge( + key: ValueKey(badgeLabel), + isLabelVisible: unreadCount > 0, + label: badgeLabel != null ? Text(badgeLabel) : null, + child: Icon( + unreadCount > 0 + ? Icons.notifications + : Icons.notifications_outlined, ), ), - ], + ), ), ); } @@ -649,3 +601,179 @@ int _currentIndex(String location, List items) { final overflowIndex = items.indexWhere((item) => item.isOverflow); return overflowIndex == -1 ? 0 : overflowIndex; } + +String _routeToTitle(String location) { + final path = location.split('?').first; + return switch (path) { + '/dashboard' => 'Dashboard', + '/attendance' => 'Attendance', + '/tickets' => 'Tickets', + '/tasks' => 'Tasks', + '/it-service-requests' => 'IT Service Requests', + '/notifications' => 'Notifications', + '/whereabouts' => 'Whereabouts', + '/workforce' => 'Workforce', + '/reports' => 'Reports', + '/announcements' => 'Announcements', + '/profile' => 'My Profile', + '/settings/users' => 'Users', + '/settings/offices' => 'Offices', + '/settings/teams' => 'IT Teams', + _ => '', + }; +} + +// ── Adaptive AppBar title ──────────────────────────────────────────────────── + +class _AppBarTitle extends StatelessWidget { + const _AppBarTitle({ + required this.location, + required this.showRail, + required this.isExtended, + }); + + final String location; + final bool showRail; + final bool isExtended; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + final pageTitle = _routeToTitle(location); + + // Shared page-name suffix: divider line + title text + List pageSuffix = pageTitle.isEmpty + ? [] + : [ + const SizedBox(width: 12), + Container(width: 1, height: 28, color: cs.outlineVariant), + const SizedBox(width: 12), + Text( + pageTitle, + style: tt.titleMedium?.copyWith( + color: cs.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ]; + + // Mobile: compact logo + brand name, no page title + if (!showRail) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Image.asset('assets/tasq_ico.png', width: 28, height: 28), + const SizedBox(width: 8), + const Text('TasQ'), + ]); + } + + // Desktop (extended rail already shows brand): logo + page name only + if (isExtended) { + return Row(mainAxisSize: MainAxisSize.min, children: [ + Image.asset('assets/tasq_ico.png', width: 28, height: 28), + ...pageSuffix, + ]); + } + + // Tablet (collapsed rail): logo + brand + page name + return Row(mainAxisSize: MainAxisSize.min, children: [ + Image.asset('assets/tasq_ico.png', width: 28, height: 28), + const SizedBox(width: 8), + const Text('TasQ'), + ...pageSuffix, + ]); + } +} + +// ── M3 profile menu anchor ─────────────────────────────────────────────────── + +class _ProfileMenuAnchor extends StatelessWidget { + const _ProfileMenuAnchor({ + required this.isStandard, + required this.displayName, + required this.avatarUrl, + required this.onProfile, + required this.onSignOut, + }); + + final bool isStandard; + final String displayName; + final String? avatarUrl; + final VoidCallback onProfile; + final VoidCallback onSignOut; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return MenuAnchor( + menuChildren: [ + MenuItemButton( + leadingIcon: const Icon(Icons.person_outline), + onPressed: onProfile, + child: const Text('My Profile'), + ), + MenuItemButton( + leadingIcon: const Icon(Icons.logout), + onPressed: onSignOut, + child: const Text('Sign out'), + ), + ], + builder: (context, controller, _) { + void toggle() => + controller.isOpen ? controller.close() : controller.open(); + + if (isStandard) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), + child: InkWell( + onTap: toggle, + borderRadius: BorderRadius.circular(24), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: cs.secondaryContainer, + borderRadius: BorderRadius.circular(24), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + ProfileAvatar( + fullName: displayName, + avatarUrl: avatarUrl, + radius: 14, + heroTag: 'profile-avatar', + ), + const SizedBox(width: 8), + Text( + displayName, + style: tt.labelLarge?.copyWith( + color: cs.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon(Icons.expand_more, size: 18, color: cs.onSecondaryContainer), + ]), + ), + ), + ); + } + + // Admin / non-standard: circular avatar icon button + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: IconButton( + tooltip: 'Account', + onPressed: toggle, + icon: ProfileAvatar( + fullName: displayName, + avatarUrl: avatarUrl, + radius: 16, + heroTag: 'profile-avatar', + ), + ), + ); + }, + ); + } +} diff --git a/lib/widgets/office_picker.dart b/lib/widgets/office_picker.dart new file mode 100644 index 00000000..e8b6bffc --- /dev/null +++ b/lib/widgets/office_picker.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; + +import '../models/office.dart'; +import '../theme/m3_motion.dart'; +import 'app_breakpoints.dart'; + +class _PickResult { + const _PickResult(this.officeId); + final String? officeId; +} + +/// A tappable form field that opens a searchable office picker. +/// +/// Replaces [DropdownButtonFormField] when the office list is large. +class OfficeSelectorField extends StatelessWidget { + const OfficeSelectorField({ + super.key, + required this.offices, + required this.selectedOfficeId, + required this.onChanged, + this.allowUnassigned = false, + this.labelText = 'Office', + }); + + final List offices; + final String? selectedOfficeId; + /// Null disables the field (shows dimmed, no tap). + final ValueChanged? onChanged; + /// When true, an "Unassigned" tile appears at the top of the picker. + final bool allowUnassigned; + final String labelText; + + String _displayText() { + if (selectedOfficeId == null) { + return allowUnassigned ? 'Unassigned' : 'Select office…'; + } + try { + return offices.firstWhere((o) => o.id == selectedOfficeId).name; + } catch (_) { + return 'Select office…'; + } + } + + Future _openPicker(BuildContext context) async { + final sorted = List.from(offices) + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + final result = await _showOfficePicker( + context, + offices: sorted, + initialOfficeId: selectedOfficeId, + allowUnassigned: allowUnassigned, + ); + if (result != null) onChanged?.call(result.officeId); + } + + @override + Widget build(BuildContext context) { + final isEnabled = onChanged != null; + final cs = Theme.of(context).colorScheme; + return InkWell( + onTap: isEnabled ? () => _openPicker(context) : null, + borderRadius: BorderRadius.circular(16), + child: InputDecorator( + decoration: InputDecoration( + labelText: labelText, + enabled: isEnabled, + suffixIcon: Icon( + Icons.search_rounded, + color: isEnabled + ? cs.onSurfaceVariant + : cs.onSurface.withValues(alpha: 0.38), + ), + ), + child: Text( + _displayText(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isEnabled + ? cs.onSurface + : cs.onSurface.withValues(alpha: 0.38), + ), + ), + ), + ); + } +} + +Future<_PickResult?> _showOfficePicker( + BuildContext context, { + required List offices, + String? initialOfficeId, + bool allowUnassigned = false, +}) { + final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; + + List buildItems( + String? selected, + String search, + void Function(String?) onPick, + ColorScheme cs, + ) { + final filtered = search.isEmpty + ? offices + : offices + .where((o) => o.name.toLowerCase().contains(search.toLowerCase())) + .toList(); + return [ + if (allowUnassigned) + ListTile( + dense: true, + leading: Icon(Icons.block_rounded, color: cs.onSurfaceVariant), + title: const Text('Unassigned'), + selected: selected == null, + trailing: selected == null + ? Icon(Icons.check_rounded, size: 18, color: cs.primary) + : null, + onTap: () => onPick(null), + ), + for (final office in filtered) + ListTile( + dense: true, + leading: Icon(Icons.business_rounded, color: cs.onSurfaceVariant), + title: Text(office.name), + selected: selected == office.id, + trailing: selected == office.id + ? Icon(Icons.check_rounded, size: 18, color: cs.primary) + : null, + onTap: () => onPick(office.id), + ), + if (filtered.isEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'No offices match your search.', + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant), + ), + ), + ]; + } + + if (isMobile) { + return m3ShowBottomSheet<_PickResult>( + context: context, + isScrollControlled: true, + builder: (sheetCtx) { + String search = ''; + String? selected = initialOfficeId; + return StatefulBuilder( + builder: (ctx, setState) { + final cs = Theme.of(ctx).colorScheme; + return SafeArea( + child: Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: MediaQuery.viewInsetsOf(ctx).bottom + 16, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Select Office', + style: Theme.of(ctx).textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + const SizedBox(height: 8), + TextField( + decoration: const InputDecoration( + hintText: 'Search office…', + prefixIcon: Icon(Icons.search_rounded), + isDense: true, + ), + onChanged: (v) => setState(() => search = v), + ), + const SizedBox(height: 4), + SizedBox( + height: 320, + child: ListView( + children: buildItems( + selected, + search, + (id) => Navigator.of(ctx).pop(_PickResult(id)), + cs, + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + + return m3ShowDialog<_PickResult>( + context: context, + builder: (dialogCtx) { + String search = ''; + String? selected = initialOfficeId; + return StatefulBuilder( + builder: (ctx, setState) { + final cs = Theme.of(ctx).colorScheme; + return AlertDialog( + title: const Text('Select Office'), + contentPadding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), + child: TextField( + decoration: const InputDecoration( + hintText: 'Search office…', + prefixIcon: Icon(Icons.search_rounded), + isDense: true, + ), + onChanged: (v) => setState(() => search = v), + ), + ), + SizedBox( + height: 280, + width: double.maxFinite, + child: ListView( + children: buildItems( + selected, + search, + (id) => Navigator.of(ctx).pop(_PickResult(id)), + cs, + ), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + ], + ); + }, + ); + }, + ); +}