* 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
+239 -184
View File
@@ -28,6 +28,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 '../../utils/subject_suggestions.dart';
@@ -324,106 +325,114 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
latestAssigneeByTaskId: latestAssigneeByTaskId,
);
final filterHeader = Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: 220,
child: TextField(
controller: _subjectController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Subject',
prefixIcon: Icon(Icons.search),
),
),
),
SizedBox(
width: 200,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedOfficeId),
initialValue: _selectedOfficeId,
items: officeOptions,
onChanged: (value) =>
setState(() => _selectedOfficeId = value),
decoration: const InputDecoration(labelText: 'Office'),
),
),
SizedBox(
width: 160,
child: TextField(
controller: _taskNumberController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
),
),
),
if (_tabController.index == 1)
SizedBox(
width: 220,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedAssigneeId),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration(
labelText: 'Assigned staff',
final filterHeader = LayoutBuilder(
builder: (context, constraints) {
final isMobile = constraints.maxWidth < 600;
final halfWidth = (constraints.maxWidth - 12) / 2;
return Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
SizedBox(
width: isMobile ? constraints.maxWidth : 220,
child: TextField(
controller: _subjectController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Subject',
prefixIcon: Icon(Icons.search),
),
),
),
),
SizedBox(
width: 180,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedStatus),
initialValue: _selectedStatus,
items: statusOptions,
onChanged: (value) =>
setState(() => _selectedStatus = value),
decoration: const InputDecoration(labelText: 'Status'),
),
),
OutlinedButton.icon(
onPressed: () async {
final next = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: AppTime.now().add(
const Duration(days: 365),
SizedBox(
width: isMobile ? halfWidth : 200,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedOfficeId),
initialValue: _selectedOfficeId,
items: officeOptions,
onChanged: (value) =>
setState(() => _selectedOfficeId = value),
decoration:
const InputDecoration(labelText: 'Office'),
),
currentDate: AppTime.now(),
initialDateRange: _selectedDateRange,
);
if (!mounted) return;
setState(() => _selectedDateRange = next);
},
icon: const Icon(Icons.date_range),
label: Text(
_selectedDateRange == null
? 'Date range'
: AppTime.formatDateRange(_selectedDateRange!),
),
),
if (_hasTaskFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedAssigneeId = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
),
SizedBox(
width: isMobile ? halfWidth : 160,
child: TextField(
controller: _taskNumberController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
),
),
),
if (_tabController.index == 1)
SizedBox(
width: isMobile ? halfWidth : 220,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedAssigneeId),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration(
labelText: 'Assigned staff',
),
),
),
SizedBox(
width: isMobile ? halfWidth : 180,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedStatus),
initialValue: _selectedStatus,
items: statusOptions,
onChanged: (value) =>
setState(() => _selectedStatus = value),
decoration:
const InputDecoration(labelText: 'Status'),
),
),
OutlinedButton.icon(
onPressed: () async {
final next = await showDateRangePicker(
context: context,
firstDate: DateTime(2020),
lastDate: AppTime.now().add(
const Duration(days: 365),
),
currentDate: AppTime.now(),
initialDateRange: _selectedDateRange,
);
if (!mounted) return;
setState(() => _selectedDateRange = next);
},
icon: const Icon(Icons.date_range),
label: Text(
_selectedDateRange == null
? 'Date range'
: AppTime.formatDateRange(_selectedDateRange!),
),
),
if (_hasTaskFilters)
TextButton.icon(
onPressed: () => setState(() {
_subjectController.clear();
_selectedOfficeId = null;
_selectedStatus = null;
_selectedAssigneeId = null;
_selectedDateRange = null;
}),
icon: const Icon(Icons.close),
label: const Text('Clear'),
),
],
);
},
);
// reusable helper for rendering a list given a subset of tasks
@@ -803,25 +812,22 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
var showTitleGemini = false;
Timer? titleTypingTimer;
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
bool saving = false;
bool titleProcessing = false;
bool descProcessing = false;
bool titleDeepSeek = false;
bool descDeepSeek = false;
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Task'),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
final isMobile = MediaQuery.sizeOf(context).width < AppBreakpoints.tablet;
// Lifted so both mobile/desktop branches share one set of form state vars.
var saving = false;
var titleProcessing = false;
var descProcessing = false;
var titleDeepSeek = false;
var descDeepSeek = false;
Column buildFormColumn(
StateSetter setState,
AsyncValue<List<Office>> officesAsync,
) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
@@ -1038,77 +1044,126 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
error: (error, _) =>
Text('Failed to load offices: $error'),
),
],
);
}
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 title = SubjectSuggestionEngine.normalizeDisplay(
titleController.text.trim(),
);
final description = descriptionController.text.trim();
final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) return;
setState(() => saving = true);
try {
await ref.read(tasksControllerProvider).createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (closeCtx.mounted) {
Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
} catch (e) {
if (!closeCtx.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(closeCtx).pop();
showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal('Failed to create task: $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) {
final officesAsync = ref.watch(officesProvider);
return 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 Task',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
buildFormColumn(setState, officesAsync),
const SizedBox(height: 16),
OverflowBar(
alignment: MainAxisAlignment.end,
children: buildActions(ctx, setState),
),
const SizedBox(height: 8),
],
),
),
actions: [
TextButton(
onPressed: saving
? null
: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: saving
? null
: () async {
final title =
SubjectSuggestionEngine.normalizeDisplay(
titleController.text.trim(),
);
final description = descriptionController.text.trim();
final officeId = selectedOfficeId;
if (title.isEmpty || officeId == null) {
return;
}
setState(() => saving = true);
try {
await ref
.read(tasksControllerProvider)
.createTask(
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" has been created successfully.',
);
}
} catch (e) {
if (!dialogContext.mounted) return;
if (isOfflineSaveError(e)) {
Navigator.of(dialogContext).pop();
showSuccessSnackBarGlobal(
'Task "$title" saved offline — will sync when connected.',
);
} else {
showErrorSnackBarGlobal('Failed to create task: $e');
}
} finally {
if (dialogContext.mounted) {
setState(() => saving = false);
}
}
},
child: saving
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Create'),
),
],
);
},
);
},
);
),
);
},
);
} else {
await m3ShowDialog<void>(
context: context,
builder: (dialogContext) {
final officesAsync = ref.watch(officesProvider);
return StatefulBuilder(
builder: (context, setState) => AlertDialog(
scrollable: true,
shape: AppSurfaces.of(context).dialogShape,
title: const Text('Create Task'),
content: SizedBox(
width: 600,
child: buildFormColumn(setState, officesAsync),
),
actions: buildActions(dialogContext, setState),
),
);
},
);
}
}
bool _hasTaskMention(