* Task/Ticket desktop dialogs — widened from 520 → 600dp, and tasks now uses SizedBox(width: 600) instead of ConstrainedBox(maxWidth:) which was the root cause of the "still small" issue (max-only never forced expansion)
* Pass Slip + Leave — both dialogs gained an isSheet flag; callers now branch on AppBreakpoints.tablet: bottom sheet on mobile, dialog on desktop
This commit is contained in:
@@ -21,6 +21,7 @@ import '../../widgets/tasq_adaptive_list.dart';
|
||||
import '../../widgets/typing_dots.dart';
|
||||
import '../../theme/app_surfaces.dart';
|
||||
import '../../utils/snackbar.dart';
|
||||
import '../../widgets/app_breakpoints.dart';
|
||||
import '../../widgets/app_page_header.dart';
|
||||
import '../../widgets/app_state_view.dart';
|
||||
import '../../widgets/sync_pending_badge.dart';
|
||||
@@ -441,152 +442,163 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||
final descriptionController = TextEditingController();
|
||||
Office? selectedOffice;
|
||||
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
bool saving = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
title: const Text('Create Ticket'),
|
||||
content: Consumer(
|
||||
builder: (context, ref, child) {
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
return SizedBox(
|
||||
width: 360,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Subject',
|
||||
),
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description',
|
||||
),
|
||||
maxLines: 3,
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) {
|
||||
return const Text('No offices assigned.');
|
||||
}
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort(
|
||||
(a, b) => a.name.toLowerCase().compareTo(
|
||||
b.name.toLowerCase(),
|
||||
),
|
||||
);
|
||||
selectedOffice ??= officesSorted.first;
|
||||
return DropdownButtonFormField<Office>(
|
||||
key: ValueKey(selectedOffice?.id),
|
||||
initialValue: selectedOffice,
|
||||
items: officesSorted
|
||||
.map(
|
||||
(office) => DropdownMenuItem(
|
||||
value: office,
|
||||
child: Text(office.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: saving
|
||||
? null
|
||||
: (value) =>
|
||||
setState(() => selectedOffice = value),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Office',
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (error, _) =>
|
||||
Text('Failed to load offices: $error'),
|
||||
),
|
||||
],
|
||||
),
|
||||
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
|
||||
var saving = false;
|
||||
|
||||
Widget buildFormContent(StateSetter setState) {
|
||||
return Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final officesAsync = ref.watch(officesProvider);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: subjectController,
|
||||
decoration: const InputDecoration(labelText: 'Subject'),
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: descriptionController,
|
||||
decoration: const InputDecoration(labelText: 'Description'),
|
||||
maxLines: 3,
|
||||
enabled: !saving,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
officesAsync.when(
|
||||
data: (offices) {
|
||||
if (offices.isEmpty) return const Text('No offices assigned.');
|
||||
final officesSorted = List<Office>.from(offices)
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
selectedOffice ??= officesSorted.first;
|
||||
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(),
|
||||
error: (e, _) => Text('Failed to load offices: $e'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
if (subject.isEmpty ||
|
||||
description.isEmpty ||
|
||||
selectedOffice == null) {
|
||||
showWarningSnackBar(
|
||||
context,
|
||||
'Fill out all fields.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() => saving = true);
|
||||
try {
|
||||
final pendingTicket = await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.createTicket(
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: selectedOffice!.id,
|
||||
);
|
||||
if (pendingTicket != null) {
|
||||
// Offline: show immediately in the list until sync.
|
||||
final current = ref.read(offlinePendingTicketsProvider);
|
||||
ref.read(offlinePendingTicketsProvider.notifier).state = [
|
||||
pendingTicket,
|
||||
...current,
|
||||
];
|
||||
}
|
||||
if (context.mounted) {
|
||||
Navigator.of(dialogContext).pop();
|
||||
showSuccessSnackBar(
|
||||
context,
|
||||
pendingTicket != null
|
||||
? 'Ticket "$subject" saved offline — will sync when connected.'
|
||||
: 'Ticket "$subject" has been created successfully.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!dialogContext.mounted) return;
|
||||
showErrorSnackBarGlobal('Failed to create ticket: $e');
|
||||
} finally {
|
||||
if (dialogContext.mounted) {
|
||||
setState(() => saving = false);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> buildActions(BuildContext closeCtx, StateSetter setState) {
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: saving ? null : () => Navigator.of(closeCtx).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: saving
|
||||
? null
|
||||
: () async {
|
||||
final subject = subjectController.text.trim();
|
||||
final description = descriptionController.text.trim();
|
||||
if (subject.isEmpty || description.isEmpty || selectedOffice == null) {
|
||||
showWarningSnackBar(closeCtx, 'Fill out all fields.');
|
||||
return;
|
||||
}
|
||||
setState(() => saving = true);
|
||||
try {
|
||||
final pendingTicket = await ref
|
||||
.read(ticketsControllerProvider)
|
||||
.createTicket(
|
||||
subject: subject,
|
||||
description: description,
|
||||
officeId: selectedOffice!.id,
|
||||
);
|
||||
if (pendingTicket != null) {
|
||||
final current = ref.read(offlinePendingTicketsProvider);
|
||||
ref.read(offlinePendingTicketsProvider.notifier).state = [
|
||||
pendingTicket,
|
||||
...current,
|
||||
];
|
||||
}
|
||||
if (closeCtx.mounted) {
|
||||
Navigator.of(closeCtx).pop();
|
||||
showSuccessSnackBar(
|
||||
closeCtx,
|
||||
pendingTicket != null
|
||||
? 'Ticket "$subject" saved offline — will sync when connected.'
|
||||
: 'Ticket "$subject" has been created successfully.',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!closeCtx.mounted) return;
|
||||
showErrorSnackBarGlobal('Failed to create ticket: $e');
|
||||
} finally {
|
||||
if (closeCtx.mounted) setState(() => saving = false);
|
||||
}
|
||||
},
|
||||
child: saving
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Create'),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
await m3ShowBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
builder: (ctx) => StatefulBuilder(
|
||||
builder: (context, setState) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom + 24,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text('Create Ticket', style: Theme.of(context).textTheme.titleLarge),
|
||||
const SizedBox(height: 16),
|
||||
buildFormContent(setState),
|
||||
const SizedBox(height: 16),
|
||||
OverflowBar(
|
||||
alignment: MainAxisAlignment.end,
|
||||
children: buildActions(ctx, setState),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await m3ShowDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setState) => AlertDialog(
|
||||
shape: AppSurfaces.of(context).dialogShape,
|
||||
title: const Text('Create Ticket'),
|
||||
content: SizedBox(
|
||||
width: 600,
|
||||
child: buildFormContent(setState),
|
||||
),
|
||||
actions: buildActions(dialogContext, setState),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, bool> _unreadByTicketId(
|
||||
|
||||
Reference in New Issue
Block a user