AI Subject, Description and Action taken support

This commit is contained in:
Marc Rejohn Castillano 2026-03-04 00:38:35 +08:00
parent b5449f7842
commit c123c09233
4 changed files with 205 additions and 142 deletions

View File

@ -334,7 +334,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
.cast<Map<String, dynamic>>() .cast<Map<String, dynamic>>()
.map(Task.fromMap) .map(Task.fromMap)
.toList(); .toList();
final hash = tasks.fold('', (h, t) => '$h${t.id}'); final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
if (!controller.isClosed && hash != lastResultHash) { if (!controller.isClosed && hash != lastResultHash) {
lastResultHash = hash; lastResultHash = hash;
controller.add(tasks); // emit immediately no debounce controller.add(tasks); // emit immediately no debounce
@ -365,7 +365,7 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
}) })
.listen( .listen(
(tasks) { (tasks) {
final hash = tasks.fold('', (h, t) => '$h${t.id}'); final hash = tasks.fold('', (h, t) => '$h${t.hashCode}');
if (hash != lastResultHash) { if (hash != lastResultHash) {
lastResultHash = hash; lastResultHash = hash;
emitDebounced(tasks); emitDebounced(tasks);

View File

@ -1,5 +1,6 @@
// ignore_for_file: use_build_context_synchronously // ignore_for_file: use_build_context_synchronously
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.dart'; import '../../models/profile.dart';
@ -12,7 +13,6 @@ import '../../models/office.dart';
import '../../providers/notifications_provider.dart'; import '../../providers/notifications_provider.dart';
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill; import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../../providers/services_provider.dart'; import '../../providers/services_provider.dart';
@ -99,6 +99,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
// Rich text editor for Action taken // Rich text editor for Action taken
quill.QuillController? _actionController; quill.QuillController? _actionController;
Timer? _actionDebounce; 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 FocusNode _actionFocusNode;
late final ScrollController _actionScrollController; late final ScrollController _actionScrollController;
Timer? _requestedDebounce; Timer? _requestedDebounce;
@ -320,18 +324,33 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
_actionController = quill.QuillController.basic(); _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(() { _actionController?.addListener(() {
final currentPlain =
_actionController?.document.toPlainText().trim() ?? '';
if (currentPlain == _actionLastPlain) return; // no text change
_actionLastPlain = currentPlain;
_actionDebounce?.cancel(); _actionDebounce?.cancel();
_actionDebounce = Timer( _actionDebounce = Timer(
const Duration(milliseconds: 700), const Duration(milliseconds: 700),
() async { () async {
final plain = if (!mounted) return;
_actionController?.document.toPlainText().trim() ?? ''; final plain = currentPlain;
setState(() { if (mounted) {
_actionSaving = true; setState(() {
_actionSaved = false; _actionSaving = true;
}); _actionSaved = false;
});
}
try { try {
final deltaJson = jsonEncode( final deltaJson = jsonEncode(
_actionController?.document.toDelta().toJson(), _actionController?.document.toDelta().toJson(),
@ -339,15 +358,19 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
await ref await ref
.read(tasksControllerProvider) .read(tasksControllerProvider)
.updateTask(taskId: task.id, actionTaken: deltaJson); .updateTask(taskId: task.id, actionTaken: deltaJson);
setState(() { if (mounted) {
_actionSaved = plain.isNotEmpty; setState(() {
}); _actionSaved = plain.isNotEmpty;
} catch (_) { });
// ignore }
} catch (e) {
debugPrint('[TasQ] action-taken auto-save error: $e');
} finally { } finally {
setState(() { if (mounted) {
_actionSaving = false; setState(() {
}); _actionSaving = false;
});
}
if (_actionSaved) { if (_actionSaved) {
Future.delayed(const Duration(seconds: 2), () { Future.delayed(const Duration(seconds: 2), () {
if (mounted) { if (mounted) {
@ -3442,42 +3465,17 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
]; ];
String? selectedOffice = task.officeId; String? selectedOffice = task.officeId;
// ---- Title-field AI-button debounce state ---- // ---- Title-field AI-button visibility ----
// We listen to titleCtrl directly instead of using TypeAheadFormField's // The button is hidden by default and shown only after the user pauses
// onChanged, because TypeAheadFormField fires onChanged unpredictably // manual typing (700 ms debounce). We can NOT rely on onChanged or
// (on focus, on overlay teardown, on suggestion selection) and we have // controller listeners because TypeAheadFormField fires those
// no reliable way to distinguish those from real user keystrokes. // 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 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 titleDeepSeek = false;
var descDeepSeek = false; var descDeepSeek = false;
Timer? titleTypingTimer; 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 { try {
await showDialog<void>( await showDialog<void>(
@ -3488,7 +3486,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
var descProcessing = false; var descProcessing = false;
return StatefulBuilder( return StatefulBuilder(
builder: (context, setDialogState) { builder: (context, setDialogState) {
titleSetState = setDialogState; // wire listener setDialogState
return AlertDialog( return AlertDialog(
shape: dialogShape, shape: dialogShape,
title: const Text('Edit Task'), title: const Text('Edit Task'),
@ -3502,44 +3499,69 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
child: GeminiAnimatedBorder( child: GeminiAnimatedBorder(
isProcessing: titleProcessing, isProcessing: titleProcessing,
useDeepSeekColors: titleDeepSeek, useDeepSeekColors: titleDeepSeek,
child: TypeAheadFormField<String>( child: KeyboardListener(
textFieldConfiguration: TextFieldConfiguration( focusNode: FocusNode(),
controller: titleCtrl, onKeyEvent: (event) {
enabled: !saving, // Only react to actual key-down (typing).
decoration: const InputDecoration( if (event is! KeyDownEvent &&
labelText: 'Title', event is! KeyRepeatEvent) {
), return;
// Debounce is handled by titleCtrl.addListener }
// (declared above showDialog) to avoid // Skip modifier-only keys.
// TypeAheadFormField's unpredictable onChanged if (event.character == null ||
// behaviour (fires on focus, overlay teardown, event.character!.isEmpty) {
// and suggestion selection). return;
), }
suggestionsCallback: (pattern) async { titleTypingTimer?.cancel();
return SubjectSuggestionEngine.suggest( if (showTitleGemini) {
existingSubjects: existingSubjects, setDialogState(
query: pattern, () => showTitleGemini = false,
limit: 8, );
}
titleTypingTimer = Timer(
const Duration(milliseconds: 700),
() {
if (titleCtrl.text.trim().isNotEmpty) {
setDialogState(
() => showTitleGemini = true,
);
}
},
); );
}, },
itemBuilder: (context, suggestion) => ListTile( child: TypeAheadFormField<String>(
dense: true, textFieldConfiguration:
title: Text(suggestion), TextFieldConfiguration(
), controller: titleCtrl,
onSuggestionSelected: (suggestion) { enabled: !saving,
// Suppress the titleCtrl listener while we decoration: const InputDecoration(
// set text programmatically so it doesn't labelText: 'Title',
// start the debounce timer. ),
titleSuppressListener = true; ),
titleTypingTimer?.cancel(); suggestionsCallback: (pattern) async {
titleCtrl return SubjectSuggestionEngine.suggest(
..text = suggestion existingSubjects: existingSubjects,
..selection = TextSelection.collapsed( query: pattern,
offset: suggestion.length, 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<TaskDetailScreen>
GeminiButton( GeminiButton(
textController: titleCtrl, textController: titleCtrl,
onTextUpdated: (updatedText) { onTextUpdated: (updatedText) {
// Suppress the ctrl listener so the AI-improved titleTypingTimer?.cancel();
// text doesn't restart the show-button debounce.
titleSuppressListener = true;
setDialogState(() { setDialogState(() {
titleCtrl.text = updatedText; titleCtrl.text = updatedText;
showTitleGemini = false;
}); });
titleSuppressListener = false;
setDialogState(() => showTitleGemini = false);
}, },
onProcessingStateChanged: (isProcessing) { onProcessingStateChanged: (isProcessing) {
setDialogState(() { setDialogState(() {
@ -3730,7 +3749,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
}, },
); );
} finally { } finally {
titleCtrl.removeListener(titleListener);
titleTypingTimer?.cancel(); titleTypingTimer?.cancel();
} }
} }
@ -3770,10 +3788,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
'professional English. Return ONLY the improved text, no explanations:', 'professional English. Return ONLY the improved text, no explanations:',
); );
setState(() { if (mounted) {
_actionSaving = true; setState(() {
_actionProcessing = true; _actionSaving = true;
}); _actionProcessing = true;
});
}
try { try {
final aiService = AiService(); final aiService = AiService();
final improvedText = await aiService.enhanceText( final improvedText = await aiService.enhanceText(
@ -3794,6 +3814,9 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
.updateTask(taskId: widget.taskId, actionTaken: deltaJson); .updateTask(taskId: widget.taskId, actionTaken: deltaJson);
if (_actionController != null) { 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; final docLen = _actionController!.document.length;
_actionController!.replaceText( _actionController!.replaceText(
0, 0,
@ -3801,7 +3824,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
trimmed, trimmed,
TextSelection.collapsed(offset: trimmed.length), TextSelection.collapsed(offset: trimmed.length),
); );
// Cancel the auto-save listener triggered by replaceText. // Cancel any debounce that may have slipped through.
_actionDebounce?.cancel(); _actionDebounce?.cancel();
} }
@ -3817,10 +3840,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
).showSnackBar(SnackBar(content: Text('Error: $e'))); ).showSnackBar(SnackBar(content: Text('Error: $e')));
} }
} finally { } finally {
setState(() { if (mounted) {
_actionSaving = false; setState(() {
_actionProcessing = false; _actionSaving = false;
}); _actionProcessing = false;
});
}
} }
} }

View File

@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart'; import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:tasq/utils/app_time.dart'; import 'package:tasq/utils/app_time.dart';
@ -582,6 +585,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
String? requestTypeOther; String? requestTypeOther;
String? selectedRequestCategory; String? selectedRequestCategory;
var showTitleGemini = false;
Timer? titleTypingTimer;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
@ -607,52 +613,84 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
child: GeminiAnimatedBorder( child: GeminiAnimatedBorder(
isProcessing: titleProcessing, isProcessing: titleProcessing,
useDeepSeekColors: titleDeepSeek, useDeepSeekColors: titleDeepSeek,
child: TypeAheadFormField<String>( child: KeyboardListener(
textFieldConfiguration: TextFieldConfiguration( focusNode: FocusNode(),
controller: titleController, onKeyEvent: (event) {
decoration: const InputDecoration( if (event is! KeyDownEvent &&
labelText: 'Task title', event is! KeyRepeatEvent) {
), return;
enabled: !saving, }
), if (event.character == null ||
suggestionsCallback: (pattern) async { event.character!.isEmpty) {
return SubjectSuggestionEngine.suggest( return;
existingSubjects: existingSubjects, }
query: pattern, titleTypingTimer?.cancel();
limit: 8, if (showTitleGemini) {
setState(() => showTitleGemini = false);
}
titleTypingTimer = Timer(
const Duration(milliseconds: 700),
() {
if (titleController.text
.trim()
.isNotEmpty) {
setState(() => showTitleGemini = true);
}
},
); );
}, },
itemBuilder: (context, suggestion) => ListTile( child: TypeAheadFormField<String>(
dense: true, textFieldConfiguration: TextFieldConfiguration(
title: Text(suggestion), controller: titleController,
), decoration: const InputDecoration(
onSuggestionSelected: (suggestion) { labelText: 'Task title',
titleController ),
..text = suggestion enabled: !saving,
..selection = TextSelection.collapsed( ),
offset: suggestion.length, 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( if (showTitleGemini)
textController: titleController, GeminiButton(
onTextUpdated: (updatedText) { textController: titleController,
setState(() { onTextUpdated: (updatedText) {
titleController.text = updatedText; titleTypingTimer?.cancel();
}); setState(() {
}, titleController.text = updatedText;
onProcessingStateChanged: (isProcessing) { showTitleGemini = false;
setState(() { });
titleProcessing = isProcessing; },
}); onProcessingStateChanged: (isProcessing) {
}, setState(() {
onProviderChanged: (isDeepSeek) { titleProcessing = isProcessing;
setState(() => titleDeepSeek = isDeepSeek); });
}, },
tooltip: 'Improve task title with Gemini', onProviderChanged: (isDeepSeek) {
), setState(() => titleDeepSeek = isDeepSeek);
},
tooltip: 'Improve task title with Gemini',
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),

View File

@ -108,7 +108,7 @@ class _GeminiButtonState extends State<GeminiButton> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Image.asset( : Image.asset(
'assets/gemini_icon.png', 'gemini_icon.png',
width: 24, width: 24,
height: 24, height: 24,
errorBuilder: (context, error, stackTrace) => errorBuilder: (context, error, stackTrace) =>