From c123c09233c91719c0130f87d1c26c6c7fd5d300 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Wed, 4 Mar 2026 00:38:35 +0800 Subject: [PATCH] AI Subject, Description and Action taken support --- lib/providers/tasks_provider.dart | 4 +- lib/screens/tasks/task_detail_screen.dart | 223 ++++++++++++---------- lib/screens/tasks/tasks_list_screen.dart | 118 ++++++++---- lib/widgets/gemini_button.dart | 2 +- 4 files changed, 205 insertions(+), 142 deletions(-) diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 779636c2..0e44fca0 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -334,7 +334,7 @@ final tasksProvider = StreamProvider>((ref) { .cast>() .map(Task.fromMap) .toList(); - final hash = tasks.fold('', (h, t) => '$h${t.id}'); + final hash = tasks.fold('', (h, t) => '$h${t.hashCode}'); if (!controller.isClosed && hash != lastResultHash) { lastResultHash = hash; controller.add(tasks); // emit immediately – no debounce @@ -365,7 +365,7 @@ final tasksProvider = StreamProvider>((ref) { }) .listen( (tasks) { - final hash = tasks.fold('', (h, t) => '$h${t.id}'); + final hash = tasks.fold('', (h, t) => '$h${t.hashCode}'); if (hash != lastResultHash) { lastResultHash = hash; emitDebounced(tasks); diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 2cb6ac4a..9d9a4b42 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -1,5 +1,6 @@ // ignore_for_file: use_build_context_synchronously import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../models/profile.dart'; @@ -12,7 +13,6 @@ import '../../models/office.dart'; import '../../providers/notifications_provider.dart'; import 'dart:async'; import 'dart:convert'; -import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; import 'package:flutter_quill/flutter_quill.dart' as quill; import '../../providers/services_provider.dart'; @@ -99,6 +99,10 @@ class _TaskDetailScreenState extends ConsumerState // Rich text editor for Action taken quill.QuillController? _actionController; Timer? _actionDebounce; + + /// Tracks the last plain-text snapshot so the addListener callback can + /// distinguish real edits from cursor / selection / focus notifications. + String _actionLastPlain = ''; late final FocusNode _actionFocusNode; late final ScrollController _actionScrollController; Timer? _requestedDebounce; @@ -320,18 +324,33 @@ class _TaskDetailScreenState extends ConsumerState _actionController = quill.QuillController.basic(); } - // Attach auto-save listener for action taken (debounced) + // Snapshot current content so the listener can skip non-edit + // notifications (cursor moves, selection changes, focus events). + _actionLastPlain = + _actionController?.document.toPlainText().trim() ?? ''; + + // Attach auto-save listener for action taken (debounced). + // QuillController fires notifications on every cursor / + // selection / formatting change — not just text edits. We + // compare plaintext to avoid endlessly resetting the timer. _actionController?.addListener(() { + final currentPlain = + _actionController?.document.toPlainText().trim() ?? ''; + if (currentPlain == _actionLastPlain) return; // no text change + _actionLastPlain = currentPlain; + _actionDebounce?.cancel(); _actionDebounce = Timer( const Duration(milliseconds: 700), () async { - final plain = - _actionController?.document.toPlainText().trim() ?? ''; - setState(() { - _actionSaving = true; - _actionSaved = false; - }); + if (!mounted) return; + final plain = currentPlain; + if (mounted) { + setState(() { + _actionSaving = true; + _actionSaved = false; + }); + } try { final deltaJson = jsonEncode( _actionController?.document.toDelta().toJson(), @@ -339,15 +358,19 @@ class _TaskDetailScreenState extends ConsumerState await ref .read(tasksControllerProvider) .updateTask(taskId: task.id, actionTaken: deltaJson); - setState(() { - _actionSaved = plain.isNotEmpty; - }); - } catch (_) { - // ignore + if (mounted) { + setState(() { + _actionSaved = plain.isNotEmpty; + }); + } + } catch (e) { + debugPrint('[TasQ] action-taken auto-save error: $e'); } finally { - setState(() { - _actionSaving = false; - }); + if (mounted) { + setState(() { + _actionSaving = false; + }); + } if (_actionSaved) { Future.delayed(const Duration(seconds: 2), () { if (mounted) { @@ -3442,42 +3465,17 @@ class _TaskDetailScreenState extends ConsumerState ]; String? selectedOffice = task.officeId; - // ---- Title-field AI-button debounce state ---- - // We listen to titleCtrl directly instead of using TypeAheadFormField's - // onChanged, because TypeAheadFormField fires onChanged unpredictably - // (on focus, on overlay teardown, on suggestion selection) and we have - // no reliable way to distinguish those from real user keystrokes. + // ---- Title-field AI-button visibility ---- + // The button is hidden by default and shown only after the user pauses + // manual typing (700 ms debounce). We can NOT rely on onChanged or + // controller listeners because TypeAheadFormField fires those + // unpredictably (on focus, overlay teardown, suggestion selection). + // Instead we wrap the field in a KeyboardListener and only react to + // actual key events. var showTitleGemini = false; - var titleSuppressListener = - false; // true while we set text programmatically - // Tracks the last known text so the listener can detect actual changes - // (vs spurious notifications from attaching the controller to a TextField). - var titleLastText = titleCtrl.text; var titleDeepSeek = false; var descDeepSeek = false; Timer? titleTypingTimer; - // Assigned by StatefulBuilder on every build so the listener can call it. - void Function(VoidCallback)? titleSetState; - - void titleListener() { - if (titleSuppressListener) return; - // Only react when the text actually changed — the controller fires - // notifications on attach/focus/selection changes too. - if (titleCtrl.text == titleLastText) return; - titleLastText = titleCtrl.text; - titleTypingTimer?.cancel(); - // Hide immediately. - titleSetState?.call(() => showTitleGemini = false); - // Show after the user pauses typing. - if (titleCtrl.text.isNotEmpty) { - titleTypingTimer = Timer( - const Duration(milliseconds: 700), - () => titleSetState?.call(() => showTitleGemini = true), - ); - } - } - - titleCtrl.addListener(titleListener); try { await showDialog( @@ -3488,7 +3486,6 @@ class _TaskDetailScreenState extends ConsumerState var descProcessing = false; return StatefulBuilder( builder: (context, setDialogState) { - titleSetState = setDialogState; // wire listener → setDialogState return AlertDialog( shape: dialogShape, title: const Text('Edit Task'), @@ -3502,44 +3499,69 @@ class _TaskDetailScreenState extends ConsumerState child: GeminiAnimatedBorder( isProcessing: titleProcessing, useDeepSeekColors: titleDeepSeek, - child: TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: titleCtrl, - enabled: !saving, - decoration: const InputDecoration( - labelText: 'Title', - ), - // Debounce is handled by titleCtrl.addListener - // (declared above showDialog) to avoid - // TypeAheadFormField's unpredictable onChanged - // behaviour (fires on focus, overlay teardown, - // and suggestion selection). - ), - suggestionsCallback: (pattern) async { - return SubjectSuggestionEngine.suggest( - existingSubjects: existingSubjects, - query: pattern, - limit: 8, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) { + // Only react to actual key-down (typing). + if (event is! KeyDownEvent && + event is! KeyRepeatEvent) { + return; + } + // Skip modifier-only keys. + if (event.character == null || + event.character!.isEmpty) { + return; + } + titleTypingTimer?.cancel(); + if (showTitleGemini) { + setDialogState( + () => showTitleGemini = false, + ); + } + titleTypingTimer = Timer( + const Duration(milliseconds: 700), + () { + if (titleCtrl.text.trim().isNotEmpty) { + setDialogState( + () => showTitleGemini = true, + ); + } + }, ); }, - itemBuilder: (context, suggestion) => ListTile( - dense: true, - title: Text(suggestion), - ), - onSuggestionSelected: (suggestion) { - // Suppress the titleCtrl listener while we - // set text programmatically so it doesn't - // start the debounce timer. - titleSuppressListener = true; - titleTypingTimer?.cancel(); - titleCtrl - ..text = suggestion - ..selection = TextSelection.collapsed( - offset: suggestion.length, + child: TypeAheadFormField( + textFieldConfiguration: + TextFieldConfiguration( + controller: titleCtrl, + enabled: !saving, + decoration: const InputDecoration( + labelText: 'Title', + ), + ), + suggestionsCallback: (pattern) async { + return SubjectSuggestionEngine.suggest( + existingSubjects: existingSubjects, + query: pattern, + limit: 8, ); - titleSuppressListener = false; - setDialogState(() => showTitleGemini = false); - }, + }, + itemBuilder: (context, suggestion) => + ListTile( + dense: true, + title: Text(suggestion), + ), + onSuggestionSelected: (suggestion) { + titleTypingTimer?.cancel(); + titleCtrl + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + setDialogState( + () => showTitleGemini = false, + ); + }, + ), ), ), ), @@ -3549,14 +3571,11 @@ class _TaskDetailScreenState extends ConsumerState GeminiButton( textController: titleCtrl, onTextUpdated: (updatedText) { - // Suppress the ctrl listener so the AI-improved - // text doesn't restart the show-button debounce. - titleSuppressListener = true; + titleTypingTimer?.cancel(); setDialogState(() { titleCtrl.text = updatedText; + showTitleGemini = false; }); - titleSuppressListener = false; - setDialogState(() => showTitleGemini = false); }, onProcessingStateChanged: (isProcessing) { setDialogState(() { @@ -3730,7 +3749,6 @@ class _TaskDetailScreenState extends ConsumerState }, ); } finally { - titleCtrl.removeListener(titleListener); titleTypingTimer?.cancel(); } } @@ -3770,10 +3788,12 @@ class _TaskDetailScreenState extends ConsumerState 'professional English. Return ONLY the improved text, no explanations:', ); - setState(() { - _actionSaving = true; - _actionProcessing = true; - }); + if (mounted) { + setState(() { + _actionSaving = true; + _actionProcessing = true; + }); + } try { final aiService = AiService(); final improvedText = await aiService.enhanceText( @@ -3794,6 +3814,9 @@ class _TaskDetailScreenState extends ConsumerState .updateTask(taskId: widget.taskId, actionTaken: deltaJson); if (_actionController != null) { + // Update the snapshot BEFORE replaceText so the listener sees no + // content change and skips the redundant auto-save. + _actionLastPlain = trimmed; final docLen = _actionController!.document.length; _actionController!.replaceText( 0, @@ -3801,7 +3824,7 @@ class _TaskDetailScreenState extends ConsumerState trimmed, TextSelection.collapsed(offset: trimmed.length), ); - // Cancel the auto-save listener triggered by replaceText. + // Cancel any debounce that may have slipped through. _actionDebounce?.cancel(); } @@ -3817,10 +3840,12 @@ class _TaskDetailScreenState extends ConsumerState ).showSnackBar(SnackBar(content: Text('Error: $e'))); } } finally { - setState(() { - _actionSaving = false; - _actionProcessing = false; - }); + if (mounted) { + setState(() { + _actionSaving = false; + _actionProcessing = false; + }); + } } } diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index add2271d..53ed8764 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:tasq/utils/app_time.dart'; @@ -582,6 +585,9 @@ class _TasksListScreenState extends ConsumerState String? requestTypeOther; String? selectedRequestCategory; + var showTitleGemini = false; + Timer? titleTypingTimer; + await showDialog( context: context, builder: (dialogContext) { @@ -607,52 +613,84 @@ class _TasksListScreenState extends ConsumerState child: GeminiAnimatedBorder( isProcessing: titleProcessing, useDeepSeekColors: titleDeepSeek, - child: TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: titleController, - decoration: const InputDecoration( - labelText: 'Task title', - ), - enabled: !saving, - ), - suggestionsCallback: (pattern) async { - return SubjectSuggestionEngine.suggest( - existingSubjects: existingSubjects, - query: pattern, - limit: 8, + child: KeyboardListener( + focusNode: FocusNode(), + onKeyEvent: (event) { + if (event is! KeyDownEvent && + event is! KeyRepeatEvent) { + return; + } + if (event.character == null || + event.character!.isEmpty) { + return; + } + titleTypingTimer?.cancel(); + if (showTitleGemini) { + setState(() => showTitleGemini = false); + } + titleTypingTimer = Timer( + const Duration(milliseconds: 700), + () { + if (titleController.text + .trim() + .isNotEmpty) { + setState(() => showTitleGemini = true); + } + }, ); }, - itemBuilder: (context, suggestion) => ListTile( - dense: true, - title: Text(suggestion), - ), - onSuggestionSelected: (suggestion) { - titleController - ..text = suggestion - ..selection = TextSelection.collapsed( - offset: suggestion.length, + child: TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Task title', + ), + 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) { + titleTypingTimer?.cancel(); + titleController + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + setState(() => showTitleGemini = false); + }, + ), ), ), ), - GeminiButton( - textController: titleController, - onTextUpdated: (updatedText) { - setState(() { - titleController.text = updatedText; - }); - }, - onProcessingStateChanged: (isProcessing) { - setState(() { - titleProcessing = isProcessing; - }); - }, - onProviderChanged: (isDeepSeek) { - setState(() => titleDeepSeek = isDeepSeek); - }, - tooltip: 'Improve task title with Gemini', - ), + if (showTitleGemini) + GeminiButton( + textController: titleController, + onTextUpdated: (updatedText) { + titleTypingTimer?.cancel(); + setState(() { + titleController.text = updatedText; + showTitleGemini = false; + }); + }, + onProcessingStateChanged: (isProcessing) { + setState(() { + titleProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setState(() => titleDeepSeek = isDeepSeek); + }, + tooltip: 'Improve task title with Gemini', + ), ], ), const SizedBox(height: 12), diff --git a/lib/widgets/gemini_button.dart b/lib/widgets/gemini_button.dart index 5af1a44e..be970a9c 100644 --- a/lib/widgets/gemini_button.dart +++ b/lib/widgets/gemini_button.dart @@ -108,7 +108,7 @@ class _GeminiButtonState extends State { child: CircularProgressIndicator(strokeWidth: 2), ) : Image.asset( - 'assets/gemini_icon.png', + 'gemini_icon.png', width: 24, height: 24, errorBuilder: (context, error, stackTrace) =>