diff --git a/assets/gemini.png b/assets/gemini.png new file mode 100644 index 00000000..e539633a Binary files /dev/null and b/assets/gemini.png differ diff --git a/gemini-input-glow.png b/gemini-input-glow.png new file mode 100644 index 00000000..42c3afc4 Binary files /dev/null and b/gemini-input-glow.png differ diff --git a/lib/screens/tasks/task_detail_screen.dart b/lib/screens/tasks/task_detail_screen.dart index 2506ead1..2cb6ac4a 100644 --- a/lib/screens/tasks/task_detail_screen.dart +++ b/lib/screens/tasks/task_detail_screen.dart @@ -35,6 +35,9 @@ import '../../widgets/status_pill.dart'; import '../../theme/app_surfaces.dart'; import '../../widgets/task_assignment_section.dart'; import '../../widgets/typing_dots.dart'; +import '../../widgets/gemini_button.dart'; +import '../../widgets/gemini_animated_text_field.dart'; +import '../../services/ai_service.dart'; // Simple image embed builder to support data-URI and network images class _ImageEmbedBuilder extends quill.EmbedBuilder { @@ -115,6 +118,7 @@ class _TaskDetailScreenState extends ConsumerState bool _categorySaved = false; bool _actionSaving = false; bool _actionSaved = false; + bool _actionProcessing = false; late final AnimationController _saveAnimController; late final Animation _savePulse; static const List _statusOptions = [ @@ -1579,334 +1583,55 @@ class _TaskDetailScreenState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Action taken'), + Row( + children: [ + const Text('Action taken'), + const Spacer(), + IconButton( + tooltip: + 'Improve action taken with Gemini', + icon: Image.asset( + 'assets/gemini_icon.png', + width: 24, + height: 24, + errorBuilder: + (context, error, stackTrace) { + return const Icon( + Icons.auto_awesome, + ); + }, + ), + onPressed: () => + _processActionTakenWithGemini( + context, + ref, + ), + ), + ], + ), const SizedBox(height: 6), // Toolbar + editor with inline save indicator - Container( - height: isWide ? 260 : 220, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of( - context, - ).colorScheme.outline, + GeminiAnimatedBorder( + isProcessing: _actionProcessing, + child: Container( + height: isWide ? 260 : 220, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all( + color: Theme.of( + context, + ).colorScheme.outline, + ), + borderRadius: BorderRadius.circular( + 8, + ), ), - borderRadius: BorderRadius.circular( - 8, - ), - ), - child: Stack( - children: [ - Column( - children: [ - isWide - ? Row( - children: [ - IconButton( - tooltip: 'Bold', - icon: const Icon( - Icons.format_bold, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .bold, - ), - ), - IconButton( - tooltip: 'Italic', - icon: const Icon( - Icons - .format_italic, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .italic, - ), - ), - IconButton( - tooltip: - 'Underline', - icon: const Icon( - Icons - .format_underlined, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .underline, - ), - ), - IconButton( - tooltip: - 'Bullet list', - icon: const Icon( - Icons - .format_list_bulleted, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ul, - ), - ), - IconButton( - tooltip: - 'Numbered list', - icon: const Icon( - Icons - .format_list_numbered, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .ol, - ), - ), - const SizedBox( - width: 8, - ), - IconButton( - tooltip: - 'Heading 2', - icon: const Icon( - Icons.format_size, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h2, - ), - ), - IconButton( - tooltip: - 'Heading 3', - icon: const Icon( - Icons.format_size, - size: 18, - ), - onPressed: () => - _actionController - ?.formatSelection( - quill - .Attribute - .h3, - ), - ), - IconButton( - tooltip: 'Undo', - icon: const Icon( - Icons.undo, - ), - onPressed: () => - _actionController - ?.undo(), - ), - IconButton( - tooltip: 'Redo', - icon: const Icon( - Icons.redo, - ), - onPressed: () => - _actionController - ?.redo(), - ), - IconButton( - tooltip: - 'Insert link', - icon: const Icon( - Icons.link, - ), - onPressed: () async { - final urlCtrl = - TextEditingController(); - final res = await showDialog( - context: - context, - builder: (ctx) => AlertDialog( - title: const Text( - 'Insert link', - ), - content: TextField( - controller: - urlCtrl, - decoration: - const InputDecoration( - hintText: - 'https://', - ), - ), - actions: [ - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop(), - child: const Text( - 'Cancel', - ), - ), - TextButton( - onPressed: () => - Navigator.of( - ctx, - ).pop( - urlCtrl.text.trim(), - ), - child: const Text( - 'Insert', - ), - ), - ], - ), - ); - if (res == null || - res.isEmpty) { - return; - } - final sel = - _actionController - ?.selection ?? - const TextSelection.collapsed( - offset: 0, - ); - final start = sel - .baseOffset; - final end = sel - .extentOffset; - if (!sel.isCollapsed && - end > start) { - final len = - end - start; - try { - _actionController - ?.document - .delete( - start, - len, - ); - } catch (_) {} - _actionController - ?.document - .insert( - start, - res, - ); - } else { - _actionController - ?.document - .insert( - start, - res, - ); - } - }, - ), - IconButton( - tooltip: - 'Insert image', - icon: const Icon( - Icons.image, - ), - onPressed: () async { - try { - final r = await FilePicker - .platform - .pickFiles( - withData: - true, - type: FileType - .image, - ); - if (r == null || - r - .files - .isEmpty) { - return; - } - final file = r - .files - .first; - final bytes = - file.bytes; - if (bytes == - null) { - return; - } - final ext = - file.extension ?? - 'png'; - String? url; - try { - url = await ref - .read( - tasksControllerProvider, - ) - .uploadActionImage( - taskId: - task.id, - bytes: - bytes, - extension: - ext, - ); - } catch (e) { - showErrorSnackBar( - context, - 'Upload error: $e', - ); - return; - } - if (url == - null) { - showErrorSnackBar( - context, - 'Image upload failed (no URL returned)', - ); - return; - } - final trimmedUrl = - url.trim(); - final idx = - _actionController - ?.selection - .baseOffset ?? - 0; - // ignore: avoid_print - print( - 'inserting image embed idx=$idx url=$trimmedUrl', - ); - _actionController - ?.document - .insert( - idx, - quill - .BlockEmbed.image( - trimmedUrl, - ), - ); - } catch (_) {} - }, - ), - ], - ) - : SingleChildScrollView( - scrollDirection: - Axis.horizontal, - child: Row( + child: Stack( + children: [ + Column( + children: [ + isWide + ? Row( children: [ IconButton( tooltip: 'Bold', @@ -2213,76 +1938,395 @@ class _TaskDetailScreenState extends ConsumerState }, ), ], + ) + : SingleChildScrollView( + scrollDirection: + Axis.horizontal, + child: Row( + children: [ + IconButton( + tooltip: 'Bold', + icon: const Icon( + Icons + .format_bold, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .bold, + ), + ), + IconButton( + tooltip: + 'Italic', + icon: const Icon( + Icons + .format_italic, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .italic, + ), + ), + IconButton( + tooltip: + 'Underline', + icon: const Icon( + Icons + .format_underlined, + ), + onPressed: () => + _actionController?.formatSelection( + quill + .Attribute + .underline, + ), + ), + IconButton( + tooltip: + 'Bullet list', + icon: const Icon( + Icons + .format_list_bulleted, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .ul, + ), + ), + IconButton( + tooltip: + 'Numbered list', + icon: const Icon( + Icons + .format_list_numbered, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .ol, + ), + ), + const SizedBox( + width: 8, + ), + IconButton( + tooltip: + 'Heading 2', + icon: const Icon( + Icons + .format_size, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .h2, + ), + ), + IconButton( + tooltip: + 'Heading 3', + icon: const Icon( + Icons + .format_size, + size: 18, + ), + onPressed: () => + _actionController + ?.formatSelection( + quill + .Attribute + .h3, + ), + ), + IconButton( + tooltip: 'Undo', + icon: + const Icon( + Icons + .undo, + ), + onPressed: () => + _actionController + ?.undo(), + ), + IconButton( + tooltip: 'Redo', + icon: + const Icon( + Icons + .redo, + ), + onPressed: () => + _actionController + ?.redo(), + ), + IconButton( + tooltip: + 'Insert link', + icon: + const Icon( + Icons + .link, + ), + onPressed: () async { + final urlCtrl = + TextEditingController(); + final res = await showDialog( + context: + context, + builder: (ctx) => AlertDialog( + title: const Text( + 'Insert link', + ), + content: TextField( + controller: + urlCtrl, + decoration: const InputDecoration( + hintText: + 'https://', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of( + ctx, + ).pop(), + child: const Text( + 'Cancel', + ), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop( + urlCtrl.text.trim(), + ), + child: const Text( + 'Insert', + ), + ), + ], + ), + ); + if (res == + null || + res.isEmpty) { + return; + } + final sel = + _actionController + ?.selection ?? + const TextSelection.collapsed( + offset: + 0, + ); + final start = + sel.baseOffset; + final end = sel + .extentOffset; + if (!sel.isCollapsed && + end > + start) { + final len = + end - + start; + try { + _actionController + ?.document + .delete( + start, + len, + ); + } catch ( + _ + ) {} + _actionController + ?.document + .insert( + start, + res, + ); + } else { + _actionController + ?.document + .insert( + start, + res, + ); + } + }, + ), + IconButton( + tooltip: + 'Insert image', + icon: const Icon( + Icons.image, + ), + onPressed: () async { + try { + final r = await FilePicker + .platform + .pickFiles( + withData: + true, + type: + FileType.image, + ); + if (r == + null || + r + .files + .isEmpty) { + return; + } + final file = r + .files + .first; + final bytes = + file.bytes; + if (bytes == + null) { + return; + } + final ext = + file.extension ?? + 'png'; + String? url; + try { + url = await ref + .read( + tasksControllerProvider, + ) + .uploadActionImage( + taskId: + task.id, + bytes: + bytes, + extension: + ext, + ); + } catch ( + e + ) { + showErrorSnackBar( + context, + 'Upload error: $e', + ); + return; + } + if (url == + null) { + showErrorSnackBar( + context, + 'Image upload failed (no URL returned)', + ); + return; + } + final trimmedUrl = + url.trim(); + final idx = + _actionController + ?.selection + .baseOffset ?? + 0; + // ignore: avoid_print + print( + 'inserting image embed idx=$idx url=$trimmedUrl', + ); + _actionController + ?.document + .insert( + idx, + quill + .BlockEmbed.image( + trimmedUrl, + ), + ); + } catch (_) {} + }, + ), + ], + ), + ), + Expanded( + child: MouseRegion( + cursor: SystemMouseCursors + .text, + child: quill.QuillEditor.basic( + controller: + _actionController!, + focusNode: + _actionFocusNode, + scrollController: + _actionScrollController, + config: quill.QuillEditorConfig( + embedBuilders: const [ + _ImageEmbedBuilder(), + ], + scrollable: true, + padding: + EdgeInsets.zero, ), - ), - Expanded( - child: MouseRegion( - cursor: - SystemMouseCursors.text, - child: quill.QuillEditor.basic( - controller: - _actionController!, - focusNode: - _actionFocusNode, - scrollController: - _actionScrollController, - config: quill.QuillEditorConfig( - embedBuilders: const [ - _ImageEmbedBuilder(), - ], - scrollable: true, - padding: - EdgeInsets.zero, ), ), ), - ), - ], - ), - Positioned( - right: 6, - bottom: 6, - child: _actionSaving - ? SizedBox( - width: 20, - height: 20, - child: ScaleTransition( - scale: _savePulse, - child: const Icon( - Icons.save, - size: 16, - ), - ), - ) - : _actionSaved - ? SizedBox( - width: 20, - height: 20, - child: Stack( - alignment: - Alignment.center, - children: const [ - Icon( + ], + ), + Positioned( + right: 6, + bottom: 6, + child: _actionSaving + ? SizedBox( + width: 20, + height: 20, + child: ScaleTransition( + scale: _savePulse, + child: const Icon( Icons.save, size: 16, - color: Colors.green, ), - Positioned( - right: -2, - bottom: -2, - child: Icon( - Icons.check, - size: 10, + ), + ) + : _actionSaved + ? SizedBox( + width: 20, + height: 20, + child: Stack( + alignment: + Alignment.center, + children: const [ + Icon( + Icons.save, + size: 16, color: - Colors.white, + Colors.green, ), - ), - ], - ), - ) - : const SizedBox.shrink(), - ), - ], + Positioned( + right: -2, + bottom: -2, + child: Icon( + Icons.check, + size: 10, + color: Colors + .white, + ), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + ], + ), ), ), ], @@ -3398,156 +3442,386 @@ class _TaskDetailScreenState extends ConsumerState ]; String? selectedOffice = task.officeId; - await showDialog( - context: context, - builder: (dialogContext) { - var saving = false; - return StatefulBuilder( - builder: (context, setDialogState) { - return AlertDialog( - shape: dialogShape, - title: const Text('Edit Task'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: titleCtrl, - enabled: !saving, - decoration: const InputDecoration(labelText: 'Title'), - ), - suggestionsCallback: (pattern) async { - return SubjectSuggestionEngine.suggest( - existingSubjects: existingSubjects, - query: pattern, - limit: 8, - ); - }, - itemBuilder: (context, suggestion) => - ListTile(dense: true, title: Text(suggestion)), - onSuggestionSelected: (suggestion) { - titleCtrl - ..text = suggestion - ..selection = TextSelection.collapsed( - offset: suggestion.length, - ); - }, - ), - const SizedBox(height: 8), - TextField( - controller: descCtrl, - enabled: !saving, - decoration: const InputDecoration( - labelText: 'Description', - ), - maxLines: 4, - ), - const SizedBox(height: 8), - Consumer( - builder: (dialogContext, dialogRef, _) { - final officesAsync = dialogRef.watch( - officesOnceProvider, - ); - return officesAsync.when( - data: (offices) { - final officesSorted = List.from(offices) - ..sort( - (a, b) => a.name.toLowerCase().compareTo( - b.name.toLowerCase(), - ), - ); - return DropdownButtonFormField( - 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 Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: LinearProgressIndicator(), - ), - error: (error, _) => const SizedBox.shrink(), - ); - }, - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: saving - ? null - : () => Navigator.of(dialogContext).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: saving - ? null - : () async { - final title = - SubjectSuggestionEngine.normalizeDisplay( - titleCtrl.text.trim(), - ); - final desc = descCtrl.text.trim(); - setDialogState(() => saving = true); - try { - await ref - .read(tasksControllerProvider) - .updateTaskFields( - taskId: task.id, - title: title.isEmpty ? null : title, - description: desc.isEmpty ? null : desc, - officeId: selectedOffice, - ); - ref.invalidate(tasksProvider); - ref.invalidate(taskByIdProvider(task.id)); - if (!mounted) return; - Navigator.of(dialogContext).pop(); - showSuccessSnackBar(context, 'Task updated'); - } catch (e) { - if (!mounted) return; - showErrorSnackBar( - context, - 'Failed to update task: $e', - ); - } finally { - if (dialogContext.mounted) { - setDialogState(() => saving = false); - } - } - }, - child: saving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Save'), - ), - ], - ); - }, + // ---- 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. + 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( + context: context, + builder: (dialogContext) { + var saving = false; + var titleProcessing = false; + var descProcessing = false; + return StatefulBuilder( + builder: (context, setDialogState) { + titleSetState = setDialogState; // wire listener → setDialogState + return AlertDialog( + shape: dialogShape, + title: const Text('Edit Task'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + 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, + ); + }, + 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, + ); + titleSuppressListener = false; + setDialogState(() => showTitleGemini = false); + }, + ), + ), + ), + // Show Gemini button only after the user pauses + // typing and has not selected a suggestion. + if (showTitleGemini) + GeminiButton( + textController: titleCtrl, + onTextUpdated: (updatedText) { + // Suppress the ctrl listener so the AI-improved + // text doesn't restart the show-button debounce. + titleSuppressListener = true; + setDialogState(() { + titleCtrl.text = updatedText; + }); + titleSuppressListener = false; + setDialogState(() => showTitleGemini = false); + }, + onProcessingStateChanged: (isProcessing) { + setDialogState(() { + titleProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setDialogState( + () => titleDeepSeek = isDeepSeek, + ); + }, + tooltip: 'Improve task title with Gemini', + promptBuilder: (_) => + 'Fix the spelling and grammar of this IT ' + 'helpdesk ticket subject. Make it concise, ' + 'clear, and professional in English. ' + 'Return ONLY the corrected subject, ' + 'no explanations:', + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: descCtrl, + enabled: !saving, + labelText: 'Description', + maxLines: 4, + isProcessing: descProcessing, + useDeepSeekColors: descDeepSeek, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: descCtrl, + onTextUpdated: (updatedText) { + setDialogState(() { + descCtrl.text = updatedText; + }); + }, + onProcessingStateChanged: (isProcessing) { + setDialogState(() { + descProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setDialogState(() => descDeepSeek = isDeepSeek); + }, + tooltip: 'Improve description with Gemini', + promptBuilder: (_) { + final subject = titleCtrl.text.trim(); + final hint = subject.isNotEmpty + ? 'about "$subject" ' + : ''; + return 'Improve this IT helpdesk ticket ' + 'description ${hint}for clarity and ' + 'professionalism. Fix grammar and translate ' + 'to English. Return ONLY the improved ' + 'description, no explanations:'; + }, + ), + ), + ], + ), + const SizedBox(height: 8), + Consumer( + builder: (dialogContext, dialogRef, _) { + final officesAsync = dialogRef.watch( + officesOnceProvider, + ); + return officesAsync.when( + data: (offices) { + final officesSorted = List.from(offices) + ..sort( + (a, b) => a.name.toLowerCase().compareTo( + b.name.toLowerCase(), + ), + ); + return DropdownButtonFormField( + 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 Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: LinearProgressIndicator(), + ), + error: (error, _) => const SizedBox.shrink(), + ); + }, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: saving + ? null + : () => Navigator.of(dialogContext).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: saving + ? null + : () async { + final title = + SubjectSuggestionEngine.normalizeDisplay( + titleCtrl.text.trim(), + ); + final desc = descCtrl.text.trim(); + setDialogState(() => saving = true); + try { + await ref + .read(tasksControllerProvider) + .updateTaskFields( + taskId: task.id, + title: title.isEmpty ? null : title, + description: desc.isEmpty ? null : desc, + officeId: selectedOffice, + ); + ref.invalidate(tasksProvider); + ref.invalidate(taskByIdProvider(task.id)); + if (!mounted) return; + Navigator.of(dialogContext).pop(); + showSuccessSnackBar(context, 'Task updated'); + } catch (e) { + if (!mounted) return; + showErrorSnackBar( + context, + 'Failed to update task: $e', + ); + } finally { + if (dialogContext.mounted) { + setDialogState(() => saving = false); + } + } + }, + child: saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ); + }, + ); + }, + ); + } finally { + titleCtrl.removeListener(titleListener); + titleTypingTimer?.cancel(); + } + } + + /// Directly enhances the Action Taken field with Gemini — no dialog, + /// no language detection. Builds a context-aware prompt from the task's + /// title and description so the model understands what is being addressed. + Future _processActionTakenWithGemini( + BuildContext context, + WidgetRef ref, + ) async { + final plainText = _actionController?.document.toPlainText().trim() ?? ''; + if (plainText.isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter some action taken text first'), + ), + ); + return; + } + + // Use task title/description as context for a richer prompt. + final task = ref.read(taskByIdProvider(widget.taskId)); + final subject = task?.title.trim() ?? ''; + final description = task?.description.trim() ?? ''; + + final hint = StringBuffer( + 'This is the action taken / workaround for an IT helpdesk ticket', ); + if (subject.isNotEmpty) hint.write(' about "$subject"'); + if (description.isNotEmpty) { + hint.write('. Ticket description: "$description"'); + } + hint.write( + '. Fix spelling and grammar, improve clarity, and translate to ' + 'professional English. Return ONLY the improved text, no explanations:', + ); + + setState(() { + _actionSaving = true; + _actionProcessing = true; + }); + try { + final aiService = AiService(); + final improvedText = await aiService.enhanceText( + plainText, + promptInstruction: hint.toString(), + ); + + final trimmed = improvedText.trim(); + // Build delta JSON directly — [{"insert": "text\n"}] — to avoid any + // race between replaceText() and reading toDelta() immediately after. + final deltaJson = jsonEncode([ + {'insert': '$trimmed\n'}, + ]); + + _actionDebounce?.cancel(); + await ref + .read(tasksControllerProvider) + .updateTask(taskId: widget.taskId, actionTaken: deltaJson); + + if (_actionController != null) { + final docLen = _actionController!.document.length; + _actionController!.replaceText( + 0, + docLen - 1, + trimmed, + TextSelection.collapsed(offset: trimmed.length), + ); + // Cancel the auto-save listener triggered by replaceText. + _actionDebounce?.cancel(); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Action taken improved successfully')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + setState(() { + _actionSaving = false; + _actionProcessing = false; + }); + } } bool _canAssignStaff(String role) { diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 5c3f0ecf..add2271d 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -25,6 +25,8 @@ import '../../widgets/typing_dots.dart'; import '../../theme/app_surfaces.dart'; import '../../utils/snackbar.dart'; import '../../utils/subject_suggestions.dart'; +import '../../widgets/gemini_button.dart'; +import '../../widgets/gemini_animated_text_field.dart'; // request metadata options used in task creation/editing dialogs const List _requestTypeOptions = [ @@ -584,6 +586,10 @@ class _TasksListScreenState extends ConsumerState 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) { @@ -595,39 +601,94 @@ class _TasksListScreenState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ - TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: titleController, - decoration: const InputDecoration( - labelText: 'Task title', + Row( + children: [ + Expanded( + 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, + ); + }, + itemBuilder: (context, suggestion) => ListTile( + dense: true, + title: Text(suggestion), + ), + onSuggestionSelected: (suggestion) { + titleController + ..text = suggestion + ..selection = TextSelection.collapsed( + offset: suggestion.length, + ); + }, + ), + ), ), - 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, - ); - }, + 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', + ), + ], ), const SizedBox(height: 12), - TextField( - controller: descriptionController, - decoration: const InputDecoration( - labelText: 'Description', - ), - maxLines: 3, - enabled: !saving, + Row( + children: [ + Expanded( + child: GeminiAnimatedTextField( + controller: descriptionController, + labelText: 'Description', + maxLines: 3, + enabled: !saving, + isProcessing: descProcessing, + useDeepSeekColors: descDeepSeek, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: GeminiButton( + textController: descriptionController, + onTextUpdated: (updatedText) { + setState(() { + descriptionController.text = updatedText; + }); + }, + onProcessingStateChanged: (isProcessing) { + setState(() { + descProcessing = isProcessing; + }); + }, + onProviderChanged: (isDeepSeek) { + setState(() => descDeepSeek = isDeepSeek); + }, + tooltip: 'Improve description with Gemini', + ), + ), + ], ), const SizedBox(height: 12), officesAsync.when( diff --git a/lib/services/ai_service.dart b/lib/services/ai_service.dart new file mode 100644 index 00000000..c05646d7 --- /dev/null +++ b/lib/services/ai_service.dart @@ -0,0 +1,202 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:http/http.dart' as http; + +/// Unified AI text-enhancement service. +/// +/// Tries Gemini (free-tier flash/lite models) first, with automatic +/// 429-retry across all discovered models. If every Gemini model fails +/// (quota exhausted or any unrecoverable error) it seamlessly falls back +/// to the DeepSeek API. +/// +/// Usage: +/// ```dart +/// final result = await AiService().enhanceText( +/// myText, +/// promptInstruction: 'Fix grammar and translate to English …', +/// ); +/// ``` +class AiService { + static final AiService _instance = AiService._internal(); + factory AiService() => _instance; + + late final String _geminiApiKey; + late final String _deepseekApiKey; + + /// Cached Gemini model IDs (flash / lite, generateContent-capable). + List _geminiModels = []; + + AiService._internal() { + final gKey = dotenv.env['GEMINI_API_KEY']; + if (gKey == null || gKey.isEmpty) { + throw Exception('GEMINI_API_KEY not found in .env'); + } + _geminiApiKey = gKey; + + final dsKey = dotenv.env['DEEPSEEK_API_KEY']; + if (dsKey == null || dsKey.isEmpty) { + throw Exception('DEEPSEEK_API_KEY not found in .env'); + } + _deepseekApiKey = dsKey; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Fixes spelling / grammar, improves clarity, and translates [text] to + /// professional English. + /// + /// Supply [promptInstruction] to give the model field-specific context + /// (e.g. "This is an IT helpdesk ticket subject …"). If omitted a + /// sensible generic instruction is used. + /// + /// Order of preference: + /// 1. Gemini flash / lite models (auto-retry on 429 across all models) + /// 2. DeepSeek `deepseek-chat` (fallback on total Gemini failure) + /// + /// Throws only if **both** providers fail. + /// [onFallbackToDeepSeek] is called (from the same isolate) just before + /// switching to the DeepSeek provider, so callers can update UI accordingly. + /// + /// This method never throws — if both providers fail it returns [text] unchanged. + Future enhanceText( + String text, { + String? promptInstruction, + void Function()? onFallbackToDeepSeek, + }) async { + if (text.trim().isEmpty) return text; + + final instruction = + promptInstruction ?? + 'Fix spelling and grammar, improve clarity, and translate to ' + 'professional English. Return ONLY the improved text, ' + 'no explanations:'; + final prompt = '$instruction\n\n"$text"'; + + // --- 1. Try Gemini --- + try { + return await _geminiGenerate(prompt, fallback: text); + } catch (_) { + // All Gemini models failed — fall through to DeepSeek. + onFallbackToDeepSeek?.call(); + } + + // --- 2. Fallback: DeepSeek --- + try { + return await _deepseekGenerate(prompt, fallback: text); + } catch (_) { + // Both providers failed — return original text unchanged. + return text; + } + } + + // --------------------------------------------------------------------------- + // Gemini + // --------------------------------------------------------------------------- + + Future> _getGeminiModels() async { + if (_geminiModels.isNotEmpty) return _geminiModels; + + try { + final uri = Uri.parse( + 'https://generativelanguage.googleapis.com/v1beta/models' + '?key=$_geminiApiKey', + ); + final res = await http.get(uri); + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + final rawModels = (data['models'] as List?) ?? []; + final discovered = []; + for (final m in rawModels) { + final fullName = m['name'] as String? ?? ''; + final lower = fullName.toLowerCase(); + final methods = + (m['supportedGenerationMethods'] as List?) ?? []; + if (methods.contains('generateContent') && + (lower.contains('flash') || lower.contains('lite'))) { + final id = fullName.startsWith('models/') + ? fullName.substring('models/'.length) + : fullName; + discovered.add(id); + } + } + discovered.sort((a, b) => b.compareTo(a)); + _geminiModels = discovered; + } + } catch (_) { + // Fall through to hard-coded list. + } + + if (_geminiModels.isEmpty) { + _geminiModels = [ + 'gemini-2.5-flash-lite', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + ]; + } + + return _geminiModels; + } + + Future _geminiGenerate( + String prompt, { + required String fallback, + }) async { + final models = await _getGeminiModels(); + + Object? lastError; + for (final modelId in models) { + try { + final model = GenerativeModel(model: modelId, apiKey: _geminiApiKey); + final response = await model.generateContent([Content.text(prompt)]); + return response.text ?? fallback; + } catch (e) { + lastError = e; + // Try the next model regardless of error type. + } + } + + throw Exception('All Gemini models failed. Last error: $lastError'); + } + + // --------------------------------------------------------------------------- + // DeepSeek (OpenAI-compatible REST) + // --------------------------------------------------------------------------- + + Future _deepseekGenerate( + String prompt, { + required String fallback, + }) async { + const url = 'https://api.deepseek.com/chat/completions'; + final body = jsonEncode({ + 'model': 'deepseek-chat', + 'messages': [ + {'role': 'user', 'content': prompt}, + ], + }); + + final res = await http.post( + Uri.parse(url), + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer $_deepseekApiKey', + }, + body: body, + ); + + if (res.statusCode == 200) { + final data = jsonDecode(res.body) as Map; + final choices = data['choices'] as List?; + final content = choices?.firstOrNull?['message']?['content'] as String?; + return content?.trim() ?? fallback; + } + + throw Exception( + 'DeepSeek request failed (HTTP ${res.statusCode}): ${res.body}', + ); + } +} diff --git a/lib/services/gemini_service.dart b/lib/services/gemini_service.dart new file mode 100644 index 00000000..e80fb62e --- /dev/null +++ b/lib/services/gemini_service.dart @@ -0,0 +1,144 @@ +import 'dart:convert'; + +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:google_generative_ai/google_generative_ai.dart'; +import 'package:http/http.dart' as http; + +class GeminiService { + static final GeminiService _instance = GeminiService._internal(); + + late final String _apiKey; + + /// Cache of valid model IDs (flash/lite, supporting generateContent). + List _validModels = []; + + factory GeminiService() => _instance; + + GeminiService._internal() { + final apiKey = dotenv.env['GEMINI_API_KEY']; + if (apiKey == null || apiKey.isEmpty) { + throw Exception('GEMINI_API_KEY not found in .env file'); + } + _apiKey = apiKey; + } + + // --------------------------------------------------------------------------- + // Model discovery + // --------------------------------------------------------------------------- + + /// Queries the Gemini REST API for available models and caches those that + /// - support `generateContent`, AND + /// - contain "flash" or "lite" in their name (free-tier / fast models). + /// + /// Returns a stable cached list on subsequent calls. + Future> _getValidModels() async { + if (_validModels.isNotEmpty) return _validModels; + + try { + final uri = Uri.parse( + 'https://generativelanguage.googleapis.com/v1beta/models?key=$_apiKey', + ); + final response = await http.get(uri); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final rawModels = (data['models'] as List?) ?? []; + + final discovered = []; + for (final m in rawModels) { + final fullName = (m['name'] as String? ?? ''); + final lower = fullName.toLowerCase(); + final methods = + (m['supportedGenerationMethods'] as List?) ?? []; + + if (methods.contains('generateContent') && + (lower.contains('flash') || lower.contains('lite'))) { + // Strip the "models/" prefix so it can be passed directly to + // GenerativeModel(model: ...). + final id = fullName.startsWith('models/') + ? fullName.substring('models/'.length) + : fullName; + discovered.add(id); + } + } + + // Sort descending so newer/more-capable models are tried first. + discovered.sort((a, b) => b.compareTo(a)); + _validModels = discovered; + } + } catch (_) { + // Fall back to hard-coded list of known free-tier models below. + } + + // If discovery failed or returned nothing, use safe known fallbacks. + if (_validModels.isEmpty) { + _validModels = [ + 'gemini-2.5-flash-lite', + 'gemini-2.5-flash', + 'gemini-2.0-flash', + 'gemini-1.5-flash', + ]; + } + + return _validModels; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// Fixes spelling/grammar, improves clarity, and translates [text] to + /// professional English. + /// + /// Provide a custom [promptInstruction] to give Gemini field-specific + /// context (subject, description, action-taken). If omitted a sensible + /// default is used. + /// + /// Automatically retries with the next available model on 429 / quota + /// errors to minimise wasted quota calls. + Future enhanceText(String text, {String? promptInstruction}) async { + if (text.trim().isEmpty) return text; + final instruction = + promptInstruction ?? + 'Fix spelling and grammar, improve clarity, and translate to ' + 'professional English. Return ONLY the improved text, no explanations:'; + final prompt = '$instruction\n\n"$text"'; + return _generateWithRetry(prompt, fallback: text); + } + + // --------------------------------------------------------------------------- + // Internal helpers + // --------------------------------------------------------------------------- + + /// Sends [prompt] to Gemini, retrying across all valid models on 429 errors. + /// Returns the model response on success, or throws if all models fail. + Future _generateWithRetry( + String prompt, { + required String fallback, + }) async { + final models = await _getValidModels(); + + for (int i = 0; i < models.length; i++) { + try { + final model = GenerativeModel(model: models[i], apiKey: _apiKey); + final response = await model.generateContent([Content.text(prompt)]); + return response.text ?? fallback; + } catch (e) { + final msg = e.toString().toLowerCase(); + final is429 = + msg.contains('429') || + msg.contains('quota') || + msg.contains('resource_exhausted'); + + if (!is429 || i == models.length - 1) { + // Non-quota error or last model — give up. + throw Exception('Gemini request failed: $e'); + } + // Quota hit — try the next model. + } + } + + // Should not reach here, but safety fallback. + throw Exception('All Gemini models exhausted'); + } +} diff --git a/lib/utils/subject_suggestions.dart b/lib/utils/subject_suggestions.dart new file mode 100644 index 00000000..5dcd7d1e --- /dev/null +++ b/lib/utils/subject_suggestions.dart @@ -0,0 +1,237 @@ +import 'dart:math' as math; + +class SubjectSuggestionEngine { + const SubjectSuggestionEngine._(); + + static List suggest({ + required Iterable existingSubjects, + required String query, + int limit = 8, + }) { + final statsByKey = {}; + + for (final raw in existingSubjects) { + final cleaned = normalizeDisplay(raw); + if (cleaned.isEmpty) { + continue; + } + final key = normalizeKey(cleaned); + if (key.isEmpty) { + continue; + } + final stats = statsByKey.putIfAbsent( + key, + () => _SubjectStats(display: cleaned), + ); + stats.count += 1; + if (_isBetterDisplay(cleaned, stats.display)) { + stats.display = cleaned; + } + } + + if (statsByKey.isEmpty) { + return const []; + } + + final cleanedQuery = normalizeDisplay(query); + final queryKey = normalizeKey(cleanedQuery); + + final scored = + statsByKey.entries + .map((entry) { + final value = entry.value; + final score = _score( + candidateKey: entry.key, + candidateDisplay: value.display, + count: value.count, + queryKey: queryKey, + ); + return _ScoredSubject(subject: value.display, score: score); + }) + .where((entry) => entry.score > 0) + .toList() + ..sort((a, b) { + final byScore = b.score.compareTo(a.score); + if (byScore != 0) { + return byScore; + } + return a.subject.toLowerCase().compareTo(b.subject.toLowerCase()); + }); + + return scored.take(limit).map((entry) => entry.subject).toList(); + } + + static String normalizeDisplay(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) { + return ''; + } + + final compactWhitespace = trimmed.replaceAll(RegExp(r'\s+'), ' '); + final punctuationSpacing = compactWhitespace + .replaceAll(RegExp(r'\s+([,.;:!?])'), r'$1') + .replaceAll(RegExp(r'([,.;:!?])(\S)'), r'$1 $2') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + + final words = punctuationSpacing.split(' '); + final correctedWords = words.map(_correctWord).toList(growable: false); + final sentence = correctedWords.join(' ').trim(); + + if (sentence.isEmpty) { + return ''; + } + + return sentence[0].toUpperCase() + sentence.substring(1); + } + + static String normalizeKey(String input) { + final lowered = input.toLowerCase(); + return lowered + .replaceAll(RegExp(r'[^a-z0-9\s]'), ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + + static double _score({ + required String candidateKey, + required String candidateDisplay, + required int count, + required String queryKey, + }) { + final popularity = math.log(count + 1) * 0.1; + + if (queryKey.isEmpty) { + return 0.5 + popularity; + } + + final startsWith = candidateKey.startsWith(queryKey) ? 1.2 : 0.0; + final contains = + !candidateKey.startsWith(queryKey) && candidateKey.contains(queryKey) + ? 0.5 + : 0.0; + + final vectorSimilarity = _cosineSimilarity( + _tokenVector(candidateKey), + _tokenVector(queryKey), + ); + + final displayLower = candidateDisplay.toLowerCase(); + final queryLower = queryKey.toLowerCase(); + final editLikeBoost = displayLower.contains(queryLower) ? 0.25 : 0.0; + + return (vectorSimilarity * 2.0) + + startsWith + + contains + + editLikeBoost + + popularity; + } + + static Map _tokenVector(String input) { + final tokens = input + .split(' ') + .where((token) => token.isNotEmpty) + .toList(growable: false); + final vector = {}; + for (final token in tokens) { + vector[token] = (vector[token] ?? 0) + 1; + } + return vector; + } + + static double _cosineSimilarity(Map a, Map b) { + if (a.isEmpty || b.isEmpty) { + return 0; + } + + var dot = 0.0; + var normA = 0.0; + var normB = 0.0; + + for (final entry in a.entries) { + final av = entry.value.toDouble(); + normA += av * av; + final bv = b[entry.key]?.toDouble() ?? 0.0; + dot += av * bv; + } + + for (final entry in b.entries) { + final bv = entry.value.toDouble(); + normB += bv * bv; + } + + final denominator = math.sqrt(normA) * math.sqrt(normB); + if (denominator == 0) { + return 0; + } + + return dot / denominator; + } + + static String _correctWord(String rawWord) { + if (rawWord.isEmpty) { + return rawWord; + } + + final punctuationMatch = RegExp( + r'^([^a-zA-Z0-9]*)(.*?)([^a-zA-Z0-9]*)$', + ).firstMatch(rawWord); + if (punctuationMatch == null) { + return rawWord; + } + + final leading = punctuationMatch.group(1) ?? ''; + final core = punctuationMatch.group(2) ?? ''; + final trailing = punctuationMatch.group(3) ?? ''; + + if (core.isEmpty) { + return rawWord; + } + + final isAcronym = core.length > 1 && core == core.toUpperCase(); + final correctedCore = isAcronym + ? core + : core[0].toUpperCase() + core.substring(1).toLowerCase(); + + return '$leading$correctedCore$trailing'; + } + + static bool _isBetterDisplay(String candidate, String current) { + if (candidate == current) { + return false; + } + + final candidatePenalty = _displayPenalty(candidate); + final currentPenalty = _displayPenalty(current); + if (candidatePenalty != currentPenalty) { + return candidatePenalty < currentPenalty; + } + + return candidate.length < current.length; + } + + static int _displayPenalty(String value) { + var penalty = 0; + if (value.contains(RegExp(r'\s{2,}'))) { + penalty += 2; + } + if (value == value.toUpperCase()) { + penalty += 1; + } + return penalty; + } +} + +class _SubjectStats { + _SubjectStats({required this.display}); + + String display; + int count = 0; +} + +class _ScoredSubject { + _ScoredSubject({required this.subject, required this.score}); + + final String subject; + final double score; +} diff --git a/lib/widgets/gemini_animated_text_field.dart b/lib/widgets/gemini_animated_text_field.dart new file mode 100644 index 00000000..134a155b --- /dev/null +++ b/lib/widgets/gemini_animated_text_field.dart @@ -0,0 +1,280 @@ +import 'dart:math' as math; +import 'package:flutter/material.dart'; + +// How far the glow bleeds outside the child's bounds on each side. +const _kGlowSpread = 12.0; + +/// Wraps any widget with a soft outward Gemini-colored glow that animates +/// while [isProcessing] is true and disappears when false. +/// The glow paints *outside* the child's bounds without affecting layout. +class GeminiAnimatedBorder extends StatefulWidget { + final Widget child; + final bool isProcessing; + + /// Must match the border-radius of the wrapped widget so the glow follows its shape. + final double borderRadius; + + /// When true the glow uses DeepSeek blue tones instead of Gemini colours. + final bool useDeepSeekColors; + + const GeminiAnimatedBorder({ + super.key, + required this.child, + required this.isProcessing, + this.borderRadius = 8, + this.useDeepSeekColors = false, + }); + + @override + State createState() => _GeminiAnimatedBorderState(); +} + +class _GeminiAnimatedBorderState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + ); + if (widget.isProcessing) _controller.repeat(); + } + + @override + void didUpdateWidget(GeminiAnimatedBorder old) { + super.didUpdateWidget(old); + if (widget.isProcessing && !old.isProcessing) { + _controller.repeat(); + } else if (!widget.isProcessing && old.isProcessing) { + _controller.stop(); + _controller.reset(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!widget.isProcessing) return widget.child; + + return AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + // Glow layer positioned to bleed outside the child on all sides. + Positioned( + left: -_kGlowSpread, + right: -_kGlowSpread, + top: -_kGlowSpread, + bottom: -_kGlowSpread, + child: CustomPaint( + painter: _GeminiGlowPainter( + rotation: _controller.value, + borderRadius: widget.borderRadius, + glowSpread: _kGlowSpread, + deepSeekMode: widget.useDeepSeekColors, + ), + ), + ), + child!, + ], + ); + }, + ); + } +} + +/// Wraps a TextField with a rotating gradient border that animates with Gemini colors +/// when processing is active. Shows normal border appearance when not processing. +class GeminiAnimatedTextField extends StatefulWidget { + final TextEditingController controller; + final String? labelText; + final int? maxLines; + final bool enabled; + final bool isProcessing; + final InputDecoration? decoration; + + /// When true the glow uses DeepSeek blue tones instead of Gemini colours. + final bool useDeepSeekColors; + + const GeminiAnimatedTextField({ + super.key, + required this.controller, + this.labelText, + this.maxLines, + this.enabled = true, + this.isProcessing = false, + this.decoration, + this.useDeepSeekColors = false, + }); + + @override + State createState() => + _GeminiAnimatedTextFieldState(); +} + +class _GeminiAnimatedTextFieldState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 3), + ); + if (widget.isProcessing) _controller.repeat(); + } + + @override + void didUpdateWidget(GeminiAnimatedTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isProcessing && !oldWidget.isProcessing) { + _controller.repeat(); + } else if (!widget.isProcessing && oldWidget.isProcessing) { + _controller.stop(); + _controller.reset(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final field = TextField( + controller: widget.controller, + enabled: widget.enabled && !widget.isProcessing, + maxLines: widget.maxLines, + decoration: + widget.decoration ?? InputDecoration(labelText: widget.labelText), + ); + + if (!widget.isProcessing) return field; + + return AnimatedBuilder( + animation: _controller, + child: field, + builder: (context, child) { + return Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + left: -_kGlowSpread, + right: -_kGlowSpread, + top: -_kGlowSpread, + bottom: -_kGlowSpread, + child: CustomPaint( + painter: _GeminiGlowPainter( + rotation: _controller.value, + borderRadius: 4, + glowSpread: _kGlowSpread, + deepSeekMode: widget.useDeepSeekColors, + ), + ), + ), + child!, + ], + ); + }, + ); + } +} + +/// Paints a soft outward glow using layered blurred strokes. +/// The [size] passed to [paint] is LARGER than the child by [glowSpread] on +/// every side, so the rrect is inset by [glowSpread] to sit exactly on the +/// child's border, and the blur bleeds outward. +class _GeminiGlowPainter extends CustomPainter { + final double rotation; + final double borderRadius; + final double glowSpread; + final bool deepSeekMode; + + // Gemini brand colors — closed loop for a seamless sweep. + static const _geminiColors = [ + Color(0xFF4285F4), // Blue + Color(0xFFEA4335), // Red + Color(0xFFFBBC04), // Yellow + Color(0xFF34A853), // Green + Color(0xFF4285F4), // Blue (close loop) + ]; + static const _geminiStops = [0.0, 0.25, 0.5, 0.75, 1.0]; + + // DeepSeek brand colors — pure blue closed loop. + static const _deepSeekColors = [ + Color(0xFF4D9BFF), // Sky blue + Color(0xFF1A56DB), // Deep blue + Color(0xFF00CFFF), // Cyan + Color(0xFF4D9BFF), // Sky blue (close loop) + ]; + static const _deepSeekStops = [0.0, 0.33, 0.66, 1.0]; + + const _GeminiGlowPainter({ + required this.rotation, + required this.borderRadius, + required this.glowSpread, + this.deepSeekMode = false, + }); + + @override + void paint(Canvas canvas, Size size) { + // The rect that coincides with the actual child's outline. + final childRect = Rect.fromLTWH( + glowSpread, + glowSpread, + size.width - glowSpread * 2, + size.height - glowSpread * 2, + ); + final rrect = RRect.fromRectAndRadius( + childRect, + Radius.circular(borderRadius), + ); + + final colors = deepSeekMode ? _deepSeekColors : _geminiColors; + final stops = deepSeekMode ? _deepSeekStops : _geminiStops; + final shader = SweepGradient( + colors: colors, + stops: stops, + transform: GradientRotation(rotation * 2 * math.pi), + ).createShader(childRect); + + // Outer glow — wide stroke + strong blur spreads the color outward. + canvas.drawRRect( + rrect, + Paint() + ..shader = shader + ..style = PaintingStyle.stroke + ..strokeWidth = glowSpread * 1.6 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread), + ); + + // Inner glow — narrower, less blurred for a crisper halo near the border. + canvas.drawRRect( + rrect, + Paint() + ..shader = shader + ..style = PaintingStyle.stroke + ..strokeWidth = glowSpread * 0.7 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, glowSpread * 0.4), + ); + } + + @override + bool shouldRepaint(_GeminiGlowPainter old) => + old.rotation != rotation || old.deepSeekMode != deepSeekMode; +} diff --git a/lib/widgets/gemini_button.dart b/lib/widgets/gemini_button.dart new file mode 100644 index 00000000..5af1a44e --- /dev/null +++ b/lib/widgets/gemini_button.dart @@ -0,0 +1,120 @@ +import 'package:flutter/material.dart'; +import '../services/ai_service.dart'; + +typedef TextUpdateCallback = void Function(String updatedText); +typedef ProcessingStateCallback = void Function(bool isProcessing); + +/// An AI icon button that immediately enhances the text in [textController] +/// when pressed — no dialog, no language detection. +/// +/// Provide [promptBuilder] to give the AI field-specific context. It receives +/// the current (trimmed) text and must return the prompt instruction string. +class GeminiButton extends StatefulWidget { + final TextEditingController textController; + final TextUpdateCallback onTextUpdated; + final ProcessingStateCallback? onProcessingStateChanged; + final String? tooltip; + + /// Optional callback that builds the Gemini prompt instruction from the + /// current field text (called at press time, so captures live context). + final String Function(String text)? promptBuilder; + + /// Called when the active AI provider changes. + /// [isDeepSeek] is true once Gemini fails and DeepSeek takes over. + /// Called with false again when processing finishes. + final void Function(bool isDeepSeek)? onProviderChanged; + + const GeminiButton({ + super.key, + required this.textController, + required this.onTextUpdated, + this.onProcessingStateChanged, + this.tooltip, + this.promptBuilder, + this.onProviderChanged, + }); + + @override + State createState() => _GeminiButtonState(); +} + +class _GeminiButtonState extends State { + bool _isProcessing = false; + late final AiService _aiService; + + @override + void initState() { + super.initState(); + _aiService = AiService(); + } + + Future _enhance(BuildContext context) async { + final text = widget.textController.text.trim(); + if (text.isEmpty) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter some text first')), + ); + return; + } + + setState(() { + _isProcessing = true; + }); + widget.onProcessingStateChanged?.call(true); + widget.onProviderChanged?.call(false); + try { + final instruction = widget.promptBuilder?.call(text); + final improvedText = await _aiService.enhanceText( + text, + promptInstruction: instruction, + onFallbackToDeepSeek: () { + if (mounted) { + widget.onProviderChanged?.call(true); + } + }, + ); + final trimmed = improvedText.trim(); + widget.textController.text = trimmed; + widget.onTextUpdated(trimmed); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Text improved successfully')), + ); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } finally { + if (mounted) { + setState(() { + _isProcessing = false; + }); + } + widget.onProcessingStateChanged?.call(false); + widget.onProviderChanged?.call(false); + } + } + + @override + Widget build(BuildContext context) { + return IconButton( + tooltip: widget.tooltip ?? 'Improve with Gemini', + icon: _isProcessing + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Image.asset( + 'assets/gemini_icon.png', + width: 24, + height: 24, + errorBuilder: (context, error, stackTrace) => + const Icon(Icons.auto_awesome), + ), + onPressed: _isProcessing ? null : () => _enhance(context), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 8e3ad6bb..d161b2fb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -709,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.3.3" + google_generative_ai: + dependency: "direct main" + description: + name: google_generative_ai + sha256: "71f613d0247968992ad87a0eb21650a566869757442ba55a31a81be6746e0d1f" + url: "https://pub.dev" + source: hosted + version: "0.4.7" gotrue: dependency: transitive description: @@ -742,7 +750,7 @@ packages: source: hosted version: "0.15.6" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 6d7bdd45..e55feab5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,8 @@ dependencies: uuid: ^4.1.0 skeletonizer: ^2.1.3 fl_chart: ^0.70.2 + google_generative_ai: ^0.4.0 + http: ^1.2.0 dev_dependencies: flutter_test: