diff --git a/lib/core/models/prompt.dart b/lib/core/models/prompt.dart new file mode 100644 index 0000000..82a2b8e --- /dev/null +++ b/lib/core/models/prompt.dart @@ -0,0 +1,69 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class Prompt { + const Prompt({ + required this.command, + required this.title, + required this.content, + this.accessControl, + this.userId, + this.timestamp, + }); + + final String command; + final String title; + final String content; + final Map? accessControl; + final String? userId; + final int? timestamp; + + factory Prompt.fromJson(Map json) { + final rawCommand = (json['command'] as String? ?? '').trim(); + final normalizedCommand = rawCommand.startsWith('/') + ? rawCommand + : (rawCommand.isEmpty ? rawCommand : '/$rawCommand'); + + return Prompt( + command: normalizedCommand, + title: json['title'] as String? ?? '', + content: json['content'] as String? ?? '', + accessControl: json['access_control'] is Map + ? Map.from(json['access_control'] as Map) + : null, + userId: json['user_id'] as String?, + timestamp: json['timestamp'] is int + ? json['timestamp'] as int + : int.tryParse('${json['timestamp']}'), + ); + } + + Map toJson() { + return { + 'command': command, + 'title': title, + 'content': content, + if (accessControl != null) 'access_control': accessControl, + if (userId != null) 'user_id': userId, + if (timestamp != null) 'timestamp': timestamp, + }; + } + + Prompt copyWith({ + String? command, + String? title, + String? content, + Map? accessControl, + String? userId, + int? timestamp, + }) { + return Prompt( + command: command ?? this.command, + title: title ?? this.title, + content: content ?? this.content, + accessControl: accessControl ?? this.accessControl, + userId: userId ?? this.userId, + timestamp: timestamp ?? this.timestamp, + ); + } +} diff --git a/lib/core/services/prompts_service.dart b/lib/core/services/prompts_service.dart new file mode 100644 index 0000000..2edad0d --- /dev/null +++ b/lib/core/services/prompts_service.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:conduit/core/error/api_error_handler.dart'; +import 'package:conduit/core/models/prompt.dart'; +import 'package:conduit/core/providers/app_providers.dart'; +import 'package:conduit/core/services/api_service.dart'; + +class PromptsService { + const PromptsService(this._apiService); + + final ApiService _apiService; + + Future> getPrompts() async { + try { + final List> response = await _apiService + .getPrompts(); + return response + .map((item) => Prompt.fromJson(item)) + .where((prompt) => prompt.command.isNotEmpty) + .toList(); + } on DioException catch (error) { + throw ApiErrorHandler().transformError(error); + } + } +} + +final promptsServiceProvider = Provider((ref) { + final apiService = ref.watch(apiServiceProvider); + if (apiService == null) return null; + return PromptsService(apiService); +}); diff --git a/lib/features/chat/widgets/modern_chat_input.dart b/lib/features/chat/widgets/modern_chat_input.dart index 5d8be5a..48ff7f9 100644 --- a/lib/features/chat/widgets/modern_chat_input.dart +++ b/lib/features/chat/widgets/modern_chat_input.dart @@ -13,7 +13,9 @@ import 'dart:ui'; import 'dart:math' as math; import '../providers/chat_providers.dart'; import '../../tools/providers/tools_providers.dart'; +import '../../prompts/providers/prompts_providers.dart'; import '../../../core/models/tool.dart'; +import '../../../core/models/prompt.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/services/settings_service.dart'; import '../../chat/services/voice_input_service.dart'; @@ -30,6 +32,30 @@ class _InsertNewlineIntent extends Intent { const _InsertNewlineIntent(); } +class _SelectNextPromptIntent extends Intent { + const _SelectNextPromptIntent(); +} + +class _SelectPreviousPromptIntent extends Intent { + const _SelectPreviousPromptIntent(); +} + +class _DismissPromptIntent extends Intent { + const _DismissPromptIntent(); +} + +class _PromptCommandMatch { + const _PromptCommandMatch({ + required this.command, + required this.start, + required this.end, + }); + + final String command; + final int start; + final int end; +} + class ModernChatInput extends ConsumerStatefulWidget { final Function(String) onSendMessage; final bool enabled; @@ -71,6 +97,10 @@ class _ModernChatInputState extends ConsumerState String _baseTextAtStart = ''; bool _isDeactivated = false; int _lastHandledFocusTick = 0; + bool _showPromptOverlay = false; + String _currentPromptCommand = ''; + TextRange? _currentPromptRange; + int _promptSelectionIndex = 0; @override void initState() { @@ -91,16 +121,8 @@ class _ModernChatInputState extends ConsumerState // Removed ref.listen here; it must be used from build in this Riverpod version - // Listen for text changes and update only when emptiness flips - _controller.addListener(() { - final has = _controller.text.trim().isNotEmpty; - if (has != _hasText) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _isDeactivated) return; - setState(() => _hasText = has); - }); - } - }); + // Listen for text and selection changes in the composer + _controller.addListener(_handleComposerChanged); // Publish focus changes to listeners _focusNode.addListener(() { @@ -122,6 +144,7 @@ class _ModernChatInputState extends ConsumerState try { ref.read(composerHasFocusProvider.notifier).state = false; } catch (_) {} + _controller.removeListener(_handleComposerChanged); _controller.dispose(); _focusNode.dispose(); _voiceStreamSubscription?.cancel(); @@ -192,6 +215,362 @@ class _ModernChatInputState extends ConsumerState _ensureFocusedIfEnabled(); } + static final RegExp _promptCommandBoundary = RegExp(r'\s'); + + void _handleComposerChanged() { + if (!mounted || _isDeactivated) return; + + final String text = _controller.text; + final TextSelection selection = _controller.selection; + final bool hasText = text.trim().isNotEmpty; + final _PromptCommandMatch? match = _resolvePromptCommand( + text, + selection, + widget.enabled, + ); + final bool shouldShow = match != null; + final bool wasShowing = _showPromptOverlay; + final String previousCommand = _currentPromptCommand; + + bool needsUpdate = hasText != _hasText || shouldShow != _showPromptOverlay; + + if (!needsUpdate) { + if (match != null) { + final TextRange? range = _currentPromptRange; + needsUpdate = + previousCommand != match.command || + range == null || + range.start != match.start || + range.end != match.end; + } else { + needsUpdate = + _currentPromptCommand.isNotEmpty || _currentPromptRange != null; + } + } + + if (!needsUpdate) return; + + setState(() { + _hasText = hasText; + if (match != null) { + if (previousCommand != match.command) { + _promptSelectionIndex = 0; + } + _currentPromptCommand = match.command; + _currentPromptRange = TextRange(start: match.start, end: match.end); + _showPromptOverlay = true; + } else { + _currentPromptCommand = ''; + _currentPromptRange = null; + _promptSelectionIndex = 0; + _showPromptOverlay = false; + } + }); + + if (!wasShowing && shouldShow) { + // Trigger prompt fetch lazily when overlay first appears + ref.read(promptsListProvider.future); + } + } + + _PromptCommandMatch? _resolvePromptCommand( + String text, + TextSelection selection, + bool enabled, + ) { + if (!enabled) return null; + if (!selection.isValid || !selection.isCollapsed) return null; + + final int cursor = selection.start; + if (cursor < 0 || cursor > text.length) return null; + if (cursor == 0) return null; + + int start = cursor; + while (start > 0) { + final String previous = text.substring(start - 1, start); + if (_promptCommandBoundary.hasMatch(previous)) { + break; + } + start--; + } + + final String candidate = text.substring(start, cursor); + if (candidate.isEmpty || !candidate.startsWith('/')) { + return null; + } + + return _PromptCommandMatch(command: candidate, start: start, end: cursor); + } + + List _filterPrompts(List prompts) { + if (prompts.isEmpty) return const []; + final String query = _currentPromptCommand.toLowerCase(); + + final List filtered = + prompts + .where( + (prompt) => + prompt.command.toLowerCase().contains(query.trim()) && + prompt.content.isNotEmpty, + ) + .toList() + ..sort((a, b) { + final int titleCompare = a.title.toLowerCase().compareTo( + b.title.toLowerCase(), + ); + if (titleCompare != 0) return titleCompare; + return a.command.toLowerCase().compareTo(b.command.toLowerCase()); + }); + + return filtered; + } + + void _movePromptSelection(int delta) { + final AsyncValue> promptsAsync = ref.read(promptsListProvider); + final List? prompts = promptsAsync.value; + if (prompts == null || prompts.isEmpty) return; + + final List filtered = _filterPrompts(prompts); + if (filtered.isEmpty) return; + + int newIndex = _promptSelectionIndex + delta; + if (newIndex < 0) { + newIndex = 0; + } else if (newIndex >= filtered.length) { + newIndex = filtered.length - 1; + } + if (newIndex == _promptSelectionIndex) return; + + setState(() { + _promptSelectionIndex = newIndex; + }); + } + + void _confirmPromptSelection() { + final AsyncValue> promptsAsync = ref.read(promptsListProvider); + final List? prompts = promptsAsync.value; + if (prompts == null || prompts.isEmpty) return; + + final List filtered = _filterPrompts(prompts); + if (filtered.isEmpty) return; + + int index = _promptSelectionIndex; + if (index < 0) { + index = 0; + } else if (index >= filtered.length) { + index = filtered.length - 1; + } + _applyPrompt(filtered[index]); + } + + void _applyPrompt(Prompt prompt) { + final TextRange? range = _currentPromptRange; + if (range == null) return; + + final String text = _controller.text; + final String before = text.substring(0, range.start); + final String after = text.substring(range.end); + final String content = prompt.content; + final int caret = before.length + content.length; + + _controller.value = TextEditingValue( + text: '$before$content$after', + selection: TextSelection.collapsed(offset: caret), + composing: TextRange.empty, + ); + + _ensureFocusedIfEnabled(); + + setState(() { + _showPromptOverlay = false; + _currentPromptCommand = ''; + _currentPromptRange = null; + _promptSelectionIndex = 0; + }); + } + + void _hidePromptOverlay() { + if (!_showPromptOverlay) return; + setState(() { + _showPromptOverlay = false; + _currentPromptCommand = ''; + _currentPromptRange = null; + _promptSelectionIndex = 0; + }); + } + + Widget _buildPromptOverlay(BuildContext context) { + final Brightness brightness = Theme.of(context).brightness; + final overlayColor = context.conduitTheme.cardBackground; + final borderColor = context.conduitTheme.cardBorder.withValues( + alpha: brightness == Brightness.dark ? 0.6 : 0.4, + ); + + final AsyncValue> promptsAsync = ref.watch( + promptsListProvider, + ); + + return Container( + decoration: BoxDecoration( + color: overlayColor, + borderRadius: BorderRadius.circular(AppBorderRadius.card), + border: Border.all(color: borderColor, width: BorderWidth.thin), + boxShadow: [ + BoxShadow( + color: context.conduitTheme.cardShadow.withValues( + alpha: brightness == Brightness.dark ? 0.28 : 0.16, + ), + blurRadius: 22, + offset: const Offset(0, 8), + spreadRadius: -4, + ), + ], + ), + child: promptsAsync.when( + data: (prompts) { + final List filtered = _filterPrompts(prompts); + if (filtered.isEmpty) { + return _buildPromptOverlayPlaceholder( + context, + Icon( + Icons.inbox_outlined, + size: IconSize.medium, + color: context.conduitTheme.textSecondary.withValues( + alpha: Alpha.medium, + ), + ), + AppLocalizations.of(context)!.noResults, + ); + } + + int activeIndex = _promptSelectionIndex; + if (activeIndex < 0) { + activeIndex = 0; + } else if (activeIndex >= filtered.length) { + activeIndex = filtered.length - 1; + } + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240), + child: ListView.separated( + padding: const EdgeInsets.symmetric(vertical: Spacing.xs), + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemCount: filtered.length, + separatorBuilder: (context, index) => + const SizedBox(height: Spacing.xxs), + itemBuilder: (context, index) { + final prompt = filtered[index]; + final bool isSelected = index == activeIndex; + final Color highlight = isSelected + ? context.conduitTheme.navigationSelectedBackground + .withValues(alpha: 0.4) + : Colors.transparent; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(AppBorderRadius.card), + onTap: () => _applyPrompt(prompt), + child: Container( + decoration: BoxDecoration( + color: highlight, + borderRadius: BorderRadius.circular( + AppBorderRadius.card, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.xs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + prompt.command, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + color: context.conduitTheme.textPrimary, + ), + ), + if (prompt.title.trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: Spacing.xxs), + child: Text( + prompt.title, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + }, + loading: () => _buildPromptOverlayPlaceholder( + context, + SizedBox( + width: IconSize.large, + height: IconSize.large, + child: CircularProgressIndicator( + strokeWidth: BorderWidth.regular, + valueColor: AlwaysStoppedAnimation( + context.conduitTheme.loadingIndicator, + ), + ), + ), + null, + ), + error: (error, stackTrace) => _buildPromptOverlayPlaceholder( + context, + Icon( + Icons.error_outline, + size: IconSize.medium, + color: context.conduitTheme.error, + ), + null, + ), + ), + ); + } + + Widget _buildPromptOverlayPlaceholder( + BuildContext context, + Widget leading, + String? message, + ) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Spacing.sm, + vertical: Spacing.md, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + leading, + if (message != null) ...[ + const SizedBox(width: Spacing.sm), + Flexible( + child: Text( + message, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: context.conduitTheme.textSecondary, + ), + ), + ), + ], + ], + ), + ); + } + @override Widget build(BuildContext context) { ref.listen(prefilledInputTextProvider, (previous, next) { @@ -380,6 +759,16 @@ class _ModernChatInputState extends ConsumerState child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_showPromptOverlay) + Padding( + padding: const EdgeInsets.fromLTRB( + Spacing.sm, + 0, + Spacing.sm, + Spacing.xs, + ), + child: _buildPromptOverlay(context), + ), Padding( padding: const EdgeInsets.fromLTRB( Spacing.sm, @@ -439,6 +828,20 @@ class _ModernChatInputState extends ConsumerState )] = const _InsertNewlineIntent(); } + if (_showPromptOverlay) { + map[LogicalKeySet( + LogicalKeyboardKey.arrowDown, + )] = + const _SelectNextPromptIntent(); + map[LogicalKeySet( + LogicalKeyboardKey.arrowUp, + )] = + const _SelectPreviousPromptIntent(); + map[LogicalKeySet( + LogicalKeyboardKey.escape, + )] = + const _DismissPromptIntent(); + } return map; }(), child: Actions( @@ -446,6 +849,10 @@ class _ModernChatInputState extends ConsumerState _SendMessageIntent: CallbackAction<_SendMessageIntent>( onInvoke: (intent) { + if (_showPromptOverlay) { + _confirmPromptSelection(); + return null; + } _sendMessage(); return null; }, @@ -459,6 +866,33 @@ class _ModernChatInputState extends ConsumerState return null; }, ), + _SelectNextPromptIntent: + CallbackAction< + _SelectNextPromptIntent + >( + onInvoke: (intent) { + _movePromptSelection(1); + return null; + }, + ), + _SelectPreviousPromptIntent: + CallbackAction< + _SelectPreviousPromptIntent + >( + onInvoke: (intent) { + _movePromptSelection(-1); + return null; + }, + ), + _DismissPromptIntent: + CallbackAction< + _DismissPromptIntent + >( + onInvoke: (intent) { + _hidePromptOverlay(); + return null; + }, + ), }, child: TweenAnimationBuilder( tween: Tween( diff --git a/lib/features/prompts/providers/prompts_providers.dart b/lib/features/prompts/providers/prompts_providers.dart new file mode 100644 index 0000000..fa260e6 --- /dev/null +++ b/lib/features/prompts/providers/prompts_providers.dart @@ -0,0 +1,12 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:conduit/core/models/prompt.dart'; +import 'package:conduit/core/services/prompts_service.dart'; + +final promptsListProvider = FutureProvider>((ref) async { + final promptsService = ref.watch(promptsServiceProvider); + if (promptsService == null) return const []; + return promptsService.getPrompts(); +}); + +final activePromptCommandProvider = StateProvider((ref) => null);