* 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:
2026-04-30 06:45:51 +08:00
parent 48cd3bcd60
commit 783c5eb0be
19 changed files with 1224 additions and 887 deletions
+155 -143
View File
@@ -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(