* Improved Task and Ticket Detail Screen

* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
2026-05-10 17:54:03 +08:00
parent 4d0d4d5ab3
commit 30b301765b
7 changed files with 1404 additions and 524 deletions
+356 -162
View File
@@ -30,9 +30,9 @@ import '../../utils/app_time.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../utils/subject_suggestions.dart'; import '../../utils/subject_suggestions.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
import '../../widgets/office_picker.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
import '../../widgets/sync_pending_badge.dart'; import '../../widgets/sync_pending_badge.dart';
import '../../widgets/task_assignment_section.dart'; import '../../widgets/task_assignment_section.dart';
@@ -125,6 +125,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bool _actionSaving = false; bool _actionSaving = false;
bool _actionSaved = false; bool _actionSaved = false;
bool _actionProcessing = false; bool _actionProcessing = false;
bool _isUpdatingStatus = false;
bool _pauseActionInFlight = false; bool _pauseActionInFlight = false;
Timer? _elapsedTicker; Timer? _elapsedTicker;
DateTime _elapsedNow = AppTime.now(); DateTime _elapsedNow = AppTime.now();
@@ -458,62 +459,82 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
const _TaskPendingBanner(), const _TaskPendingBanner(),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
Center( // ── Hero zone ────────────────────────────────────────────────
child: Row( Container(
mainAxisSize: MainAxisSize.min, 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: [ children: [
Flexible( Row(
child: Text( crossAxisAlignment: CrossAxisAlignment.start,
task.title.isNotEmpty children: [
? task.title Expanded(
: 'Task ${task.taskNumber ?? task.id}', child: Text(
textAlign: TextAlign.center, task.title.isNotEmpty
style: Theme.of(context).textTheme.titleLarge ? task.title
?.copyWith(fontWeight: FontWeight.w700), : '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(height: 6),
const SizedBox(width: 8), Text(
Builder( _createdByLabel(profilesAsync, task, ticket),
builder: (ctx) { style: Theme.of(context).textTheme.labelMedium
final profile = profileAsync.maybeWhen( ?.copyWith(
data: (p) => p, color: Theme.of(context)
orElse: () => null, .colorScheme
); .onSurfaceVariant,
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), // ── Meta strip ───────────────────────────────────────────────
Align(
alignment: Alignment.center,
child: Text(
_createdByLabel(profilesAsync, task, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.labelMedium,
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: Wrap( child: Wrap(
spacing: 12, spacing: 8,
runSpacing: 8, runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_buildStatusChip( _buildStatusChip(
@@ -522,8 +543,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
canUpdateStatus, canUpdateStatus,
hasAssignedItStaff, hasAssignedItStaff,
), ),
_MetaBadge(label: 'Office', value: officeName),
_MetaBadge( _MetaBadge(
icon: Icons.business_rounded,
label: 'Office',
value: officeName,
),
_MetaBadge(
icon: Icons.tag_rounded,
label: 'Task #', label: 'Task #',
value: isPending && task.taskNumber == null value: isPending && task.taskNumber == null
? 'Pending' ? 'Pending'
@@ -586,7 +612,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
); );
} catch (_) {} } catch (_) {}
}, },
icon: const Icon(Icons.print), icon: const Icon(Icons.print_outlined),
), ),
], ],
), ),
@@ -622,7 +648,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
], ],
const SizedBox(height: 16), const SizedBox(height: 16),
// Collapsible tabbed details: Assignees / Type & Category / Signatories // 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'), title: const Text('Details'),
initiallyExpanded: isWide, initiallyExpanded: isWide,
childrenPadding: const EdgeInsets.symmetric(horizontal: 0), childrenPadding: const EdgeInsets.symmetric(horizontal: 0),
@@ -2647,6 +2677,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
), ),
], ],
), ),
),
),
], ],
); );
@@ -2683,8 +2715,29 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
labelColor: Theme.of(context).colorScheme.onSurface, labelColor: Theme.of(context).colorScheme.onSurface,
indicatorColor: Theme.of(context).colorScheme.primary, indicatorColor: Theme.of(context).colorScheme.primary,
tabs: const [ tabs: const [
Tab(text: 'Chat'), Tab(
Tab(text: 'Activity'), 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<TaskDetailScreen>
).textTheme.labelMedium, ).textTheme.labelMedium,
), ),
), ),
Row( Container(
children: [ decoration: BoxDecoration(
Expanded( color: Theme.of(context)
child: TextField( .colorScheme
controller: _messageController, .surfaceContainerHighest,
decoration: borderRadius:
const InputDecoration( BorderRadius.circular(28),
hintText: 'Message...', ),
), padding: const EdgeInsets.only(
textInputAction: left: 16,
TextInputAction.send, right: 4,
enabled: canSendMessages, top: 2,
onChanged: (_) => bottom: 2,
_handleComposerChanged( ),
profilesAsync child: Row(
.valueOrNull ?? children: [
[], Expanded(
ref.read( child: TextField(
currentUserIdProvider, 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, onSubmitted: (_) =>
typingChannelId, _handleSendMessage(
), task,
onSubmitted: (_) => profilesAsync
_handleSendMessage( .valueOrNull ??
task, [],
profilesAsync ref.read(
.valueOrNull ?? currentUserIdProvider,
[], ),
ref.read( canSendMessages,
currentUserIdProvider, typingChannelId,
), ),
canSendMessages, ),
typingChannelId,
),
), ),
), IconButton(
const SizedBox(width: 12), tooltip: 'Send',
IconButton( onPressed: canSendMessages
tooltip: 'Send', ? () =>
onPressed: canSendMessages _handleSendMessage(
? () => _handleSendMessage( task,
task, profilesAsync
profilesAsync .valueOrNull ??
.valueOrNull ?? [],
[], ref.read(
ref.read( currentUserIdProvider,
currentUserIdProvider, ),
), canSendMessages,
canSendMessages, typingChannelId,
typingChannelId, )
) : null,
: null, icon: const Icon(
icon: const Icon(Icons.send), Icons.send_rounded,
), ),
], ),
],
),
), ),
], ],
), ),
@@ -2969,7 +3046,36 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
List<Profile> profiles, List<Profile> profiles,
) { ) {
if (messages.isEmpty) { 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 profileById = {for (final profile in profiles) profile.id: profile};
final currentUserId = ref.read(currentUserIdProvider); final currentUserId = ref.read(currentUserIdProvider);
@@ -3276,34 +3382,55 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
DateTime at, { DateTime at, {
IconData icon = Icons.circle, IconData icon = Icons.circle,
}) { }) {
return Padding( final cs = Theme.of(context).colorScheme;
padding: const EdgeInsets.symmetric(vertical: 8), final theme = Theme.of(context);
return IntrinsicHeight(
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SizedBox( SizedBox(
width: 28, width: 32,
child: Center(
child: Icon(
icon,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
),
Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: Theme.of(context).textTheme.bodyMedium), Container(
const SizedBox(height: 4), width: 28,
Text( height: 28,
'$actor${AppTime.formatDate(at)} ${AppTime.formatTime(at)}', decoration: BoxDecoration(
style: Theme.of(context).textTheme.labelSmall, 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<TaskDetailScreen>
return AlertDialog( return AlertDialog(
shape: dialogShape, shape: dialogShape,
title: const Text('Edit Task'), title: const Text('Edit Task'),
content: SingleChildScrollView( content: SizedBox(
width: 600,
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -4034,22 +4163,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
b.name.toLowerCase(), b.name.toLowerCase(),
), ),
); );
return DropdownButtonFormField<String?>( return OfficeSelectorField(
initialValue: selectedOffice, offices: officesSorted,
decoration: const InputDecoration( selectedOfficeId: selectedOffice,
labelText: 'Office', allowUnassigned: true,
),
items: [
const DropdownMenuItem(
value: null,
child: Text('Unassigned'),
),
for (final o in officesSorted)
DropdownMenuItem(
value: o.id,
child: Text(o.name),
),
],
onChanged: saving onChanged: saving
? null ? null
: (v) => setDialogState( : (v) => setDialogState(
@@ -4068,6 +4185,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
], ],
), ),
), ),
),
actions: [ actions: [
TextButton( TextButton(
onPressed: saving onPressed: saving
@@ -4238,9 +4356,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
bool canUpdateStatus, bool canUpdateStatus,
bool hasAssignedItStaff, bool hasAssignedItStaff,
) { ) {
final chip = StatusPill( final chip = _TaskStatusChip(
label: task.status.toUpperCase(), status: task.status,
isEmphasized: task.status != 'queued', isLoading: _isUpdatingStatus,
); );
final isTerminal = final isTerminal =
@@ -4404,9 +4522,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
return; return;
} }
// Update DB only — Supabase realtime stream will emit the if (mounted) setState(() => _isUpdatingStatus = true);
// updated task list, so explicit invalidation here causes a
// visible loading/refresh and is unnecessary.
try { try {
await ref await ref
.read(tasksControllerProvider) .read(tasksControllerProvider)
@@ -4421,6 +4537,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
showErrorSnackBarGlobal(e.toString()); showErrorSnackBarGlobal(e.toString());
} }
} }
} finally {
if (mounted) setState(() => _isUpdatingStatus = false);
} }
}, },
itemBuilder: (context) => statusOptions itemBuilder: (context) => statusOptions
@@ -4684,30 +4802,106 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// PDF preview/building moved to `task_pdf.dart`. // PDF preview/building moved to `task_pdf.dart`.
} }
class _MetaBadge extends StatelessWidget { class _TaskStatusChip extends StatelessWidget {
const _MetaBadge({required this.label, required this.value, this.isMono}); const _TaskStatusChip({required this.status, this.isLoading = false});
final String status;
final String label; final bool isLoading;
final String value;
final bool? isMono;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = Theme.of(context).colorScheme.outlineVariant; final cs = Theme.of(context).colorScheme;
final background = Theme.of(context).colorScheme.surfaceContainerLow; final (icon, bg, fg) = switch (status) {
final textStyle = Theme.of(context).textTheme.labelSmall; '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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: bg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(20),
border: Border.all(color: border),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(label, style: textStyle), if (isLoading)
const SizedBox(width: 6), 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) if (isMono == true)
MonoText(value, style: textStyle) MonoText(value, style: textStyle)
else else
+5 -20
View File
@@ -10,6 +10,7 @@ import 'package:go_router/go_router.dart';
import '../../models/notification_item.dart'; import '../../models/notification_item.dart';
import '../../models/office.dart'; import '../../models/office.dart';
import '../../widgets/office_picker.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
import '../../models/task.dart'; import '../../models/task.dart';
import '../../models/task_assignment.dart'; import '../../models/task_assignment.dart';
@@ -961,31 +962,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
b.name.toLowerCase(), b.name.toLowerCase(),
), ),
); );
if (selectedOfficeId == null ||
!officesSorted.any((o) => o.id == selectedOfficeId)) {
selectedOfficeId = officesSorted.first.id;
}
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
DropdownButtonFormField<String>( OfficeSelectorField(
initialValue: selectedOfficeId, offices: officesSorted,
decoration: const InputDecoration( selectedOfficeId: selectedOfficeId,
labelText: 'Office',
),
items: officesSorted
.map(
(office) => DropdownMenuItem(
value: office.id,
child: Text(office.name),
),
)
.toList(),
onChanged: saving onChanged: saving
? null ? null
: (value) => setState( : (id) => setState(() => selectedOfficeId = id),
() => selectedOfficeId = value,
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// optional request metadata inputs // optional request metadata inputs
+549 -233
View File
@@ -16,9 +16,9 @@ import '../../providers/tickets_provider.dart';
import '../../providers/typing_provider.dart'; import '../../providers/typing_provider.dart';
import '../../utils/snackbar.dart'; import '../../utils/snackbar.dart';
import '../../widgets/app_breakpoints.dart'; import '../../widgets/app_breakpoints.dart';
import '../../widgets/office_picker.dart';
import '../../widgets/mono_text.dart'; import '../../widgets/mono_text.dart';
import '../../widgets/responsive_body.dart'; import '../../widgets/responsive_body.dart';
import '../../widgets/status_pill.dart';
import '../../widgets/task_assignment_section.dart'; import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart'; import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart'; import '../../theme/app_surfaces.dart';
@@ -38,6 +38,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
String? _mentionQuery; String? _mentionQuery;
int? _mentionStart; int? _mentionStart;
List<Profile> _mentionResults = []; List<Profile> _mentionResults = [];
bool _isUpdatingStatus = false;
@override @override
void initState() { void initState() {
@@ -99,66 +100,83 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
final detailsContent = Column( final detailsContent = Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( // ── Hero zone ──────────────────────────────────────────────
child: Row( Container(
mainAxisSize: MainAxisSize.min, 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: [ children: [
Flexible( Row(
child: Text( crossAxisAlignment: CrossAxisAlignment.start,
ticket.subject, children: [
textAlign: TextAlign.center, Expanded(
style: Theme.of(context).textTheme.titleLarge?.copyWith( child: Text(
fontWeight: FontWeight.w700, 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), const SizedBox(height: 6),
Builder( Text(
builder: (ctx) { _filedByLabel(profilesAsync, ticket),
final profile = currentProfileAsync.maybeWhen( style: Theme.of(context).textTheme.labelMedium
data: (p) => p, ?.copyWith(
orElse: () => null, color: Theme.of(
); context,
final canEdit = ).colorScheme.onSurfaceVariant,
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), // ── Meta strip ─────────────────────────────────────────────
Align(
alignment: Alignment.center,
child: Text(
_filedByLabel(profilesAsync, ticket),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( Wrap(
spacing: 12, spacing: 8,
runSpacing: 8, runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_buildStatusChip(context, ref, ticket, canPromote), _buildStatusChip(context, ref, ticket, canPromote),
_MetaBadge( _MetaBadge(
icon: Icons.business_rounded,
label: 'Office', label: 'Office',
value: _officeLabel(officesAsync, ticket), value: _officeLabel(officesAsync, ticket),
), ),
_MetaBadge( _MetaBadge(
icon: Icons.confirmation_number_rounded,
label: 'Ticket ID', label: 'Ticket ID',
value: ticket.id, value: ticket.id,
isMono: true, isMono: true,
@@ -167,37 +185,51 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// collapse the rest of the details so tall chat areas won't push off-screen // collapse the rest of the details so tall chat areas won't push off-screen
ExpansionTile( ClipRRect(
key: const Key('ticket-details-expansion'), borderRadius: BorderRadius.circular(12),
title: const Text('Details'), child: Material(
initiallyExpanded: true, color: Theme.of(context).colorScheme.surfaceContainerLow,
childrenPadding: const EdgeInsets.only(top: 8), child: ExpansionTile(
children: [ key: const Key('ticket-details-expansion'),
Text(ticket.description), title: const Text('Details'),
const SizedBox(height: 12), initiallyExpanded: true,
_buildTatRow(context, ticket, effectiveRespondedAt), childrenPadding: const EdgeInsets.only(top: 8),
if (taskForTicket != null) ...[ children: [
const SizedBox(height: 16), Padding(
Row( padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: TaskAssignmentSection( Text(ticket.description),
taskId: taskForTicket.id, const SizedBox(height: 12),
canAssign: showAssign, _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<TicketDetailScreen> {
child: messagesAsync.when( child: messagesAsync.when(
data: (messages) { data: (messages) {
if (messages.isEmpty) { 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 = { final profileById = {
for (final profile in profilesAsync.valueOrNull ?? []) for (final profile in profilesAsync.valueOrNull ?? [])
@@ -391,47 +462,66 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
), ),
), ),
Row( Container(
children: [ decoration: BoxDecoration(
Expanded( color: Theme.of(context)
child: TextField( .colorScheme
controller: _messageController, .surfaceContainerHighest,
decoration: const InputDecoration( borderRadius: BorderRadius.circular(28),
hintText: 'Message...', ),
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, IconButton(
onChanged: canSendMessages tooltip: 'Send',
? (_) => _handleComposerChanged( onPressed: canSendMessages
profilesAsync.valueOrNull ?? [], ? () => _handleSendMessage(
currentUserId,
canSendMessages,
)
: null,
onSubmitted: canSendMessages
? (_) => _handleSendMessage(
ref, ref,
profilesAsync.valueOrNull ?? [], profilesAsync.valueOrNull ?? [],
currentUserId, currentUserId,
canSendMessages, canSendMessages,
) )
: null, : 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<TicketDetailScreen> {
final descCtrl = TextEditingController(text: ticket.description); final descCtrl = TextEditingController(text: ticket.description);
String? selectedOffice = ticket.officeId; String? selectedOffice = ticket.officeId;
await m3ShowDialog<void>( final isWide =
context: context, MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet;
builder: (dialogContext) {
var saving = false; if (isWide) {
return StatefulBuilder( await m3ShowDialog<void>(
builder: (dialogBuilderContext, setDialogState) { context: context,
return AlertDialog( builder: (dialogContext) {
shape: dialogShape, var saving = false;
title: const Text('Edit Ticket'), return StatefulBuilder(
content: SingleChildScrollView( 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<Office>.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<void>(
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( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( TextField(
controller: subjectCtrl, controller: subjectCtrl,
enabled: !saving, enabled: !saving,
@@ -924,103 +1167,104 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
b.name.toLowerCase(), b.name.toLowerCase(),
), ),
); );
return DropdownButtonFormField<String?>( return OfficeSelectorField(
initialValue: selectedOffice, offices: officesSorted,
decoration: const InputDecoration( selectedOfficeId: selectedOffice,
labelText: 'Office', allowUnassigned: true,
),
items: [
const DropdownMenuItem(
value: null,
child: Text('Unassigned'),
),
for (final o in officesSorted)
DropdownMenuItem(
value: o.id,
child: Text(o.name),
),
],
onChanged: saving onChanged: saving
? null ? null
: (v) => setDialogState(() => selectedOffice = v), : (v) =>
setDialogState(() => selectedOffice = v),
); );
}, },
loading: () => const SizedBox.shrink(), loading: () => const SizedBox.shrink(),
error: (error, stack) => 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) { Widget _timelineRow(String label, DateTime? value) {
@@ -1049,9 +1293,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
bool canPromote, bool canPromote,
) { ) {
final isLocked = ticket.status == 'promoted' || ticket.status == 'closed'; final isLocked = ticket.status == 'promoted' || ticket.status == 'closed';
final chip = StatusPill( final chip = _TicketStatusChip(
label: _statusLabel(ticket.status), status: ticket.status,
isEmphasized: ticket.status != 'pending', isLoading: _isUpdatingStatus,
); );
if (isLocked) { if (isLocked) {
@@ -1064,10 +1308,16 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
return PopupMenuButton<String>( return PopupMenuButton<String>(
onSelected: (value) async { onSelected: (value) async {
// Rely on the realtime stream to propagate the status change. if (mounted) setState(() => _isUpdatingStatus = true);
await ref try {
.read(ticketsControllerProvider) await ref
.updateTicketStatus(ticketId: ticket.id, status: value); .read(ticketsControllerProvider)
.updateTicketStatus(ticketId: ticket.id, status: value);
} catch (e) {
if (mounted) showErrorSnackBarGlobal(e.toString());
} finally {
if (mounted) setState(() => _isUpdatingStatus = false);
}
}, },
itemBuilder: (context) => availableStatuses itemBuilder: (context) => availableStatuses
.map( .map(
@@ -1081,9 +1331,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
); );
} }
String _statusLabel(String status) {
return status.toUpperCase();
}
String _statusMenuLabel(String status) { String _statusMenuLabel(String status) {
return switch (status) { return switch (status) {
@@ -1103,32 +1350,101 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
} }
} }
class _MetaBadge extends StatelessWidget { class _TicketStatusChip extends StatelessWidget {
const _MetaBadge({required this.label, required this.value, this.isMono}); const _TicketStatusChip({required this.status, this.isLoading = false});
final String status;
final String label; final bool isLoading;
final String value;
final bool? isMono;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final border = Theme.of(context).colorScheme.outlineVariant; final cs = Theme.of(context).colorScheme;
final background = Theme.of(context).colorScheme.surfaceContainerLow; final (icon, bg, fg) = switch (status) {
final textStyle = Theme.of(context).textTheme.labelSmall; '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( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: background, color: bg,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(20),
AppSurfaces.of(context).compactCardRadius,
),
border: Border.all(color: border),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text(label, style: textStyle), if (isLoading)
const SizedBox(width: 6), 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) if (isMono == true)
MonoText(value, style: textStyle) MonoText(value, style: textStyle)
else else
+8 -15
View File
@@ -5,6 +5,7 @@ import 'package:tasq/utils/app_time.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../models/office.dart'; import '../../models/office.dart';
import '../../widgets/office_picker.dart';
import '../../models/notification_item.dart'; import '../../models/notification_item.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
import '../../models/ticket.dart'; import '../../models/ticket.dart';
@@ -440,7 +441,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
) async { ) async {
final subjectController = TextEditingController(); final subjectController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
Office? selectedOffice; String? selectedOfficeId;
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet; final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
var saving = false; var saving = false;
@@ -471,18 +472,10 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
if (offices.isEmpty) return const Text('No offices assigned.'); if (offices.isEmpty) return const Text('No offices assigned.');
final officesSorted = List<Office>.from(offices) final officesSorted = List<Office>.from(offices)
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
if (selectedOffice == null || return OfficeSelectorField(
!officesSorted.any((o) => o.id == selectedOffice!.id)) { offices: officesSorted,
selectedOffice = officesSorted.first; selectedOfficeId: selectedOfficeId,
} onChanged: saving ? null : (id) => setState(() => selectedOfficeId = id),
return DropdownButtonFormField<Office>(
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'),
); );
}, },
loading: () => const LinearProgressIndicator(), loading: () => const LinearProgressIndicator(),
@@ -506,7 +499,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
: () async { : () async {
final subject = subjectController.text.trim(); final subject = subjectController.text.trim();
final description = descriptionController.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.'); showWarningSnackBar(closeCtx, 'Fill out all fields.');
return; return;
} }
@@ -517,7 +510,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
.createTicket( .createTicket(
subject: subject, subject: subject,
description: description, description: description,
officeId: selectedOffice!.id, officeId: selectedOfficeId!,
); );
if (pendingTicket != null) { if (pendingTicket != null) {
final current = ref.read(offlinePendingTicketsProvider); final current = ref.read(offlinePendingTicketsProvider);
+4
View File
@@ -89,6 +89,10 @@ class AppTheme {
scrolledUnderElevation: 2, scrolledUnderElevation: 2,
surfaceTintColor: cs.surfaceTint, surfaceTintColor: cs.surfaceTint,
centerTitle: false, 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( titleTextStyle: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
letterSpacing: 0.2, letterSpacing: 0.2,
+222 -94
View File
@@ -58,69 +58,29 @@ class AppScaffold extends ConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Row( title: _AppBarTitle(
children: [ location: location,
Image.asset('assets/tasq_ico.png', width: 28, height: 28), showRail: showRail,
const SizedBox(width: 8), isExtended: isExtended,
Text('TasQ'),
],
), ),
actions: [ actions: [
if (isStandard) _ProfileMenuAnchor(
PopupMenuButton<int>( isStandard: isStandard,
tooltip: 'Account', displayName: displayName,
onSelected: (value) { avatarUrl: avatarUrl,
if (value == 0) { onProfile: () => context.go('/profile'),
context.go('/profile'); onSignOut: () => ref.read(authControllerProvider).signOut(),
} 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),
),
],
),
const _NotificationBell(), 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 bottomNavigationBar: showRail
? null ? null
@@ -279,43 +239,35 @@ class _NotificationBell extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final unreadCount = ref.watch(unreadNotificationsCountProvider); final unreadCount = ref.watch(unreadNotificationsCountProvider);
final cs = Theme.of(context).colorScheme; final String? badgeLabel = unreadCount == 0
return IconButton( ? null
tooltip: 'Notifications', : unreadCount > 99
onPressed: () => context.go('/notifications'), ? '99+'
icon: Stack( : unreadCount.toString();
clipBehavior: Clip.none,
children: [ return Padding(
const Icon(Icons.notifications_outlined), padding: const EdgeInsets.only(right: 4),
Positioned( child: IconButton(
right: -3, key: notificationBellKey,
top: -3, tooltip: 'Notifications',
child: AnimatedSwitcher( onPressed: () => context.go('/notifications'),
duration: const Duration(milliseconds: 200), icon: AnimatedSwitcher(
switchInCurve: Curves.easeOutBack, duration: const Duration(milliseconds: 200),
switchOutCurve: Curves.easeInCubic, switchInCurve: Curves.easeOutBack,
transitionBuilder: (child, animation) => ScaleTransition( switchOutCurve: Curves.easeInCubic,
scale: animation, transitionBuilder: (child, animation) =>
child: child, ScaleTransition(scale: animation, child: child),
), child: Badge(
child: unreadCount > 0 key: ValueKey(badgeLabel),
? Container( isLabelVisible: unreadCount > 0,
key: const ValueKey('badge'), label: badgeLabel != null ? Text(badgeLabel) : null,
width: 10, child: Icon(
height: 10, unreadCount > 0
decoration: BoxDecoration( ? Icons.notifications
color: cs.error, : Icons.notifications_outlined,
shape: BoxShape.circle,
border: Border.all(
color: cs.surface,
width: 1.5,
),
),
)
: const SizedBox.shrink(key: ValueKey('no-badge')),
), ),
), ),
], ),
), ),
); );
} }
@@ -649,3 +601,179 @@ int _currentIndex(String location, List<NavItem> items) {
final overflowIndex = items.indexWhere((item) => item.isOverflow); final overflowIndex = items.indexWhere((item) => item.isOverflow);
return overflowIndex == -1 ? 0 : overflowIndex; 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<Widget> 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',
),
),
);
},
);
}
}
+260
View File
@@ -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<Office> offices;
final String? selectedOfficeId;
/// Null disables the field (shows dimmed, no tap).
final ValueChanged<String?>? 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<void> _openPicker(BuildContext context) async {
final sorted = List<Office>.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<Office> offices,
String? initialOfficeId,
bool allowUnassigned = false,
}) {
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
List<Widget> 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'),
),
],
);
},
);
},
);
}