Implemented subject suggestions in Task Creation and Edit.

Fix editing redirects
This commit is contained in:
Marc Rejohn Castillano 2026-03-03 18:39:01 +08:00
parent bfcca47353
commit b1f5d209a2
2 changed files with 152 additions and 81 deletions

View File

@ -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) {

View File

@ -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'),
),
],
); );
}, },
); );