* Improved Task and Ticket Detail Screen
* Made Office assignment searchable in Task and Ticket Creation and Edit Screen
This commit is contained in:
@@ -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,22 +459,38 @@ 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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
task.title.isNotEmpty
|
task.title.isNotEmpty
|
||||||
? task.title
|
? task.title
|
||||||
: 'Task ${task.taskNumber ?? task.id}',
|
: 'Task ${task.taskNumber ?? task.id}',
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context)
|
||||||
style: Theme.of(context).textTheme.titleLarge
|
.textTheme
|
||||||
|
.headlineMedium
|
||||||
?.copyWith(fontWeight: FontWeight.w700),
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SyncPendingBadge(isPending: isPending, compact: true),
|
SyncPendingBadge(
|
||||||
const SizedBox(width: 8),
|
isPending: isPending,
|
||||||
|
compact: true,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
final profile = profileAsync.maybeWhen(
|
final profile = profileAsync.maybeWhen(
|
||||||
@@ -497,23 +514,27 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Align(
|
Text(
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
_createdByLabel(profilesAsync, task, ticket),
|
_createdByLabel(profilesAsync, task, ticket),
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
?.copyWith(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── Meta strip ───────────────────────────────────────────────
|
||||||
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,14 +2842,35 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||||||
).textTheme.labelMedium,
|
).textTheme.labelMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
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: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller:
|
||||||
decoration:
|
_messageController,
|
||||||
const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Message...',
|
hintText: 'Message...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets
|
||||||
|
.symmetric(
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
textInputAction:
|
textInputAction:
|
||||||
TextInputAction.send,
|
TextInputAction.send,
|
||||||
@@ -2826,11 +2900,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Send',
|
tooltip: 'Send',
|
||||||
onPressed: canSendMessages
|
onPressed: canSendMessages
|
||||||
? () => _handleSendMessage(
|
? () =>
|
||||||
|
_handleSendMessage(
|
||||||
task,
|
task,
|
||||||
profilesAsync
|
profilesAsync
|
||||||
.valueOrNull ??
|
.valueOrNull ??
|
||||||
@@ -2842,10 +2916,13 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||||||
typingChannelId,
|
typingChannelId,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(
|
||||||
|
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: 32,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
width: 28,
|
width: 28,
|
||||||
child: Center(
|
height: 28,
|
||||||
child: Icon(
|
decoration: BoxDecoration(
|
||||||
icon,
|
color: cs.primaryContainer,
|
||||||
size: 18,
|
shape: BoxShape.circle,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
child: Icon(icon, size: 14, color: cs.onPrimaryContainer),
|
||||||
),
|
),
|
||||||
Expanded(
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
Text(title, style: theme.textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
'$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
|
'$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,20 +100,31 @@ 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(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
ticket.subject,
|
ticket.subject,
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context)
|
||||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
.textTheme
|
||||||
fontWeight: FontWeight.w700,
|
.headlineMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w700),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Builder(
|
Builder(
|
||||||
builder: (ctx) {
|
builder: (ctx) {
|
||||||
final profile = currentProfileAsync.maybeWhen(
|
final profile = currentProfileAsync.maybeWhen(
|
||||||
@@ -137,28 +149,34 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Align(
|
Text(
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
_filedByLabel(profilesAsync, ticket),
|
_filedByLabel(profilesAsync, ticket),
|
||||||
textAlign: TextAlign.center,
|
style: Theme.of(context).textTheme.labelMedium
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// ── Meta strip ─────────────────────────────────────────────
|
||||||
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,11 +185,20 @@ 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(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Material(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||||
|
child: ExpansionTile(
|
||||||
key: const Key('ticket-details-expansion'),
|
key: const Key('ticket-details-expansion'),
|
||||||
title: const Text('Details'),
|
title: const Text('Details'),
|
||||||
initiallyExpanded: true,
|
initiallyExpanded: true,
|
||||||
childrenPadding: const EdgeInsets.only(top: 8),
|
childrenPadding: const EdgeInsets.only(top: 8),
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(ticket.description),
|
Text(ticket.description),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -199,6 +226,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -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,13 +462,32 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
style: Theme.of(context).textTheme.labelMedium,
|
style: Theme.of(context).textTheme.labelMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
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: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _messageController,
|
controller: _messageController,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Message...',
|
hintText: 'Message...',
|
||||||
|
border: InputBorder.none,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
enabled: canSendMessages,
|
enabled: canSendMessages,
|
||||||
textInputAction: TextInputAction.send,
|
textInputAction: TextInputAction.send,
|
||||||
@@ -418,7 +508,6 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Send',
|
tooltip: 'Send',
|
||||||
onPressed: canSendMessages
|
onPressed: canSendMessages
|
||||||
@@ -429,10 +518,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
canSendMessages,
|
canSendMessages,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(Icons.send_rounded),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -888,6 +978,10 @@ 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;
|
||||||
|
|
||||||
|
final isWide =
|
||||||
|
MediaQuery.sizeOf(context).width >= AppBreakpoints.tablet;
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
await m3ShowDialog<void>(
|
await m3ShowDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
@@ -897,14 +991,18 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
shape: dialogShape,
|
shape: dialogShape,
|
||||||
title: const Text('Edit Ticket'),
|
title: const Text('Edit Ticket'),
|
||||||
content: SingleChildScrollView(
|
content: SizedBox(
|
||||||
|
width: 600,
|
||||||
|
child: SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TextField(
|
||||||
controller: subjectCtrl,
|
controller: subjectCtrl,
|
||||||
enabled: !saving,
|
enabled: !saving,
|
||||||
decoration: const InputDecoration(labelText: 'Subject'),
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Subject',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -924,25 +1022,14 @@ 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(),
|
||||||
@@ -951,6 +1038,7 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: saving
|
onPressed: saving
|
||||||
@@ -971,7 +1059,8 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
.updateTicket(
|
.updateTicket(
|
||||||
ticketId: ticket.id,
|
ticketId: ticket.id,
|
||||||
subject: subject.isEmpty ? null : subject,
|
subject: subject.isEmpty ? null : subject,
|
||||||
description: desc.isEmpty ? null : desc,
|
description:
|
||||||
|
desc.isEmpty ? null : desc,
|
||||||
officeId: selectedOffice,
|
officeId: selectedOffice,
|
||||||
);
|
);
|
||||||
ref.invalidate(ticketsProvider);
|
ref.invalidate(ticketsProvider);
|
||||||
@@ -1021,6 +1110,161 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} 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(
|
||||||
|
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,
|
||||||
|
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(),
|
||||||
|
),
|
||||||
|
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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(ticketsControllerProvider)
|
.read(ticketsControllerProvider)
|
||||||
.updateTicketStatus(ticketId: ticket.id, status: value);
|
.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' => (
|
||||||
return Container(
|
Icons.hourglass_empty_rounded,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
cs.secondaryContainer,
|
||||||
decoration: BoxDecoration(
|
cs.onSecondaryContainer,
|
||||||
color: background,
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
AppSurfaces.of(context).compactCardRadius,
|
|
||||||
),
|
),
|
||||||
border: Border.all(color: border),
|
'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: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+214
-86
@@ -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) {
|
|
||||||
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,
|
avatarUrl: avatarUrl,
|
||||||
radius: 16,
|
onProfile: () => context.go('/profile'),
|
||||||
heroTag: 'profile-avatar',
|
onSignOut: () => ref.read(authControllerProvider).signOut(),
|
||||||
),
|
|
||||||
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,44 +239,36 @@ 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
|
||||||
|
: unreadCount > 99
|
||||||
|
? '99+'
|
||||||
|
: unreadCount.toString();
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: IconButton(
|
||||||
|
key: notificationBellKey,
|
||||||
tooltip: 'Notifications',
|
tooltip: 'Notifications',
|
||||||
onPressed: () => context.go('/notifications'),
|
onPressed: () => context.go('/notifications'),
|
||||||
icon: Stack(
|
icon: AnimatedSwitcher(
|
||||||
clipBehavior: Clip.none,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.notifications_outlined),
|
|
||||||
Positioned(
|
|
||||||
right: -3,
|
|
||||||
top: -3,
|
|
||||||
child: AnimatedSwitcher(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
switchInCurve: Curves.easeOutBack,
|
switchInCurve: Curves.easeOutBack,
|
||||||
switchOutCurve: Curves.easeInCubic,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
transitionBuilder: (child, animation) => ScaleTransition(
|
transitionBuilder: (child, animation) =>
|
||||||
scale: animation,
|
ScaleTransition(scale: animation, child: child),
|
||||||
child: child,
|
child: Badge(
|
||||||
),
|
key: ValueKey(badgeLabel),
|
||||||
child: unreadCount > 0
|
isLabelVisible: unreadCount > 0,
|
||||||
? Container(
|
label: badgeLabel != null ? Text(badgeLabel) : null,
|
||||||
key: const ValueKey('badge'),
|
child: Icon(
|
||||||
width: 10,
|
unreadCount > 0
|
||||||
height: 10,
|
? Icons.notifications
|
||||||
decoration: BoxDecoration(
|
: Icons.notifications_outlined,
|
||||||
color: cs.error,
|
|
||||||
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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user