Implemented subject suggestions in Task Creation and Edit.
Fix editing redirects
This commit is contained in:
parent
bfcca47353
commit
b1f5d209a2
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_typeahead/flutter_typeahead.dart';
|
||||||
import 'package:tasq/utils/app_time.dart';
|
import 'package:tasq/utils/app_time.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
|
@ -23,6 +24,7 @@ import '../../widgets/tasq_adaptive_list.dart';
|
||||||
import '../../widgets/typing_dots.dart';
|
import '../../widgets/typing_dots.dart';
|
||||||
import '../../theme/app_surfaces.dart';
|
import '../../theme/app_surfaces.dart';
|
||||||
import '../../utils/snackbar.dart';
|
import '../../utils/snackbar.dart';
|
||||||
|
import '../../utils/subject_suggestions.dart';
|
||||||
|
|
||||||
// request metadata options used in task creation/editing dialogs
|
// request metadata options used in task creation/editing dialogs
|
||||||
const List<String> _requestTypeOptions = [
|
const List<String> _requestTypeOptions = [
|
||||||
|
|
@ -565,6 +567,14 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
) async {
|
) async {
|
||||||
final titleController = TextEditingController();
|
final titleController = TextEditingController();
|
||||||
final descriptionController = TextEditingController();
|
final descriptionController = TextEditingController();
|
||||||
|
final existingSubjects = <String>[
|
||||||
|
...((ref.read(tasksProvider).valueOrNull ?? const <Task>[]).map(
|
||||||
|
(task) => task.title,
|
||||||
|
)),
|
||||||
|
...((ref.read(ticketsProvider).valueOrNull ?? const <Ticket>[]).map(
|
||||||
|
(ticket) => ticket.subject,
|
||||||
|
)),
|
||||||
|
];
|
||||||
String? selectedOfficeId;
|
String? selectedOfficeId;
|
||||||
String? selectedRequestType;
|
String? selectedRequestType;
|
||||||
String? requestTypeOther;
|
String? requestTypeOther;
|
||||||
|
|
@ -585,12 +595,30 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
TypeAheadFormField<String>(
|
||||||
controller: titleController,
|
textFieldConfiguration: TextFieldConfiguration(
|
||||||
decoration: const InputDecoration(
|
controller: titleController,
|
||||||
labelText: 'Task title',
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Task title',
|
||||||
|
),
|
||||||
|
enabled: !saving,
|
||||||
),
|
),
|
||||||
enabled: !saving,
|
suggestionsCallback: (pattern) async {
|
||||||
|
return SubjectSuggestionEngine.suggest(
|
||||||
|
existingSubjects: existingSubjects,
|
||||||
|
query: pattern,
|
||||||
|
limit: 8,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
itemBuilder: (context, suggestion) =>
|
||||||
|
ListTile(dense: true, title: Text(suggestion)),
|
||||||
|
onSuggestionSelected: (suggestion) {
|
||||||
|
titleController
|
||||||
|
..text = suggestion
|
||||||
|
..selection = TextSelection.collapsed(
|
||||||
|
offset: suggestion.length,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
TextField(
|
TextField(
|
||||||
|
|
@ -710,7 +738,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
||||||
onPressed: saving
|
onPressed: saving
|
||||||
? null
|
? null
|
||||||
: () async {
|
: () async {
|
||||||
final title = titleController.text.trim();
|
final title =
|
||||||
|
SubjectSuggestionEngine.normalizeDisplay(
|
||||||
|
titleController.text.trim(),
|
||||||
|
);
|
||||||
final description = descriptionController.text.trim();
|
final description = descriptionController.text.trim();
|
||||||
final officeId = selectedOfficeId;
|
final officeId = selectedOfficeId;
|
||||||
if (title.isEmpty || officeId == null) {
|
if (title.isEmpty || officeId == null) {
|
||||||
|
|
|
||||||
|
|
@ -890,6 +890,8 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Ticket ticket,
|
Ticket ticket,
|
||||||
) async {
|
) async {
|
||||||
|
final screenContext = context;
|
||||||
|
final dialogShape = AppSurfaces.of(context).dialogShape;
|
||||||
final officesAsync = ref.watch(officesOnceProvider);
|
final officesAsync = ref.watch(officesOnceProvider);
|
||||||
final subjectCtrl = TextEditingController(text: ticket.subject);
|
final subjectCtrl = TextEditingController(text: ticket.subject);
|
||||||
final descCtrl = TextEditingController(text: ticket.description);
|
final descCtrl = TextEditingController(text: ticket.description);
|
||||||
|
|
@ -898,85 +900,123 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
await showDialog<void>(
|
await showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) {
|
builder: (dialogContext) {
|
||||||
return AlertDialog(
|
var saving = false;
|
||||||
shape: AppSurfaces.of(context).dialogShape,
|
return StatefulBuilder(
|
||||||
title: const Text('Edit Ticket'),
|
builder: (dialogBuilderContext, setDialogState) {
|
||||||
content: SingleChildScrollView(
|
return AlertDialog(
|
||||||
child: Column(
|
shape: dialogShape,
|
||||||
mainAxisSize: MainAxisSize.min,
|
title: const Text('Edit Ticket'),
|
||||||
children: [
|
content: SingleChildScrollView(
|
||||||
TextField(
|
child: Column(
|
||||||
controller: subjectCtrl,
|
mainAxisSize: MainAxisSize.min,
|
||||||
decoration: const InputDecoration(labelText: 'Subject'),
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: subjectCtrl,
|
||||||
|
enabled: !saving,
|
||||||
|
decoration: const InputDecoration(labelText: 'Subject'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: descCtrl,
|
||||||
|
enabled: !saving,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Description',
|
||||||
|
),
|
||||||
|
maxLines: 4,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
officesAsync.when(
|
||||||
|
data: (offices) {
|
||||||
|
final officesSorted = List<Office>.from(offices)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.name.toLowerCase().compareTo(
|
||||||
|
b.name.toLowerCase(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return DropdownButtonFormField<String?>(
|
||||||
|
initialValue: selectedOffice,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Office',
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem(
|
||||||
|
value: null,
|
||||||
|
child: Text('Unassigned'),
|
||||||
|
),
|
||||||
|
for (final o in officesSorted)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: o.id,
|
||||||
|
child: Text(o.name),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: saving
|
||||||
|
? null
|
||||||
|
: (v) => setDialogState(() => selectedOffice = v),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox.shrink(),
|
||||||
|
error: (error, stack) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
TextField(
|
actions: [
|
||||||
controller: descCtrl,
|
TextButton(
|
||||||
decoration: const InputDecoration(labelText: 'Description'),
|
onPressed: saving
|
||||||
maxLines: 4,
|
? null
|
||||||
|
: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ElevatedButton(
|
||||||
officesAsync.when(
|
onPressed: saving
|
||||||
data: (offices) {
|
? null
|
||||||
final officesSorted = List<Office>.from(offices)
|
: () async {
|
||||||
..sort(
|
final subject = subjectCtrl.text.trim();
|
||||||
(a, b) => a.name.toLowerCase().compareTo(
|
final desc = descCtrl.text.trim();
|
||||||
b.name.toLowerCase(),
|
setDialogState(() => saving = true);
|
||||||
),
|
try {
|
||||||
);
|
await ref
|
||||||
return DropdownButtonFormField<String?>(
|
.read(ticketsControllerProvider)
|
||||||
initialValue: selectedOffice,
|
.updateTicket(
|
||||||
decoration: const InputDecoration(labelText: 'Office'),
|
ticketId: ticket.id,
|
||||||
items: [
|
subject: subject.isEmpty ? null : subject,
|
||||||
const DropdownMenuItem(
|
description: desc.isEmpty ? null : desc,
|
||||||
value: null,
|
officeId: selectedOffice,
|
||||||
child: Text('Unassigned'),
|
);
|
||||||
),
|
ref.invalidate(ticketsProvider);
|
||||||
for (final o in officesSorted)
|
ref.invalidate(ticketByIdProvider(ticket.id));
|
||||||
DropdownMenuItem(value: o.id, child: Text(o.name)),
|
if (!dialogContext.mounted ||
|
||||||
],
|
!screenContext.mounted) {
|
||||||
onChanged: (v) => selectedOffice = v,
|
return;
|
||||||
);
|
}
|
||||||
},
|
Navigator.of(dialogContext).pop();
|
||||||
loading: () => const SizedBox.shrink(),
|
showSuccessSnackBar(
|
||||||
error: (error, stack) => const SizedBox.shrink(),
|
screenContext,
|
||||||
|
'Ticket updated',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!screenContext.mounted) return;
|
||||||
|
showErrorSnackBar(
|
||||||
|
screenContext,
|
||||||
|
'Failed to update ticket: $e',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (dialogContext.mounted) {
|
||||||
|
setDialogState(() => saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Save'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final subject = subjectCtrl.text.trim();
|
|
||||||
final desc = descCtrl.text.trim();
|
|
||||||
try {
|
|
||||||
await ref
|
|
||||||
.read(ticketsControllerProvider)
|
|
||||||
.updateTicket(
|
|
||||||
ticketId: ticket.id,
|
|
||||||
subject: subject.isEmpty ? null : subject,
|
|
||||||
description: desc.isEmpty ? null : desc,
|
|
||||||
officeId: selectedOffice,
|
|
||||||
);
|
|
||||||
if (!mounted) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
showSuccessSnackBar(context, 'Ticket updated');
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (!mounted) return;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
showErrorSnackBar(context, 'Failed to update ticket: $e');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Save'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user