feat: enter to send option and one tap to focus keyboard

This commit is contained in:
cogwheel0
2025-09-08 01:05:48 +05:30
parent 3893e266f6
commit c78d1448b8
5 changed files with 356 additions and 108 deletions

View File

@@ -23,6 +23,8 @@ class SettingsService {
static const String _socketTransportModeKey = 'socket_transport_mode'; // 'auto' or 'ws'
// Quick pill visibility selections (max 2)
static const String _quickPillsKey = 'quick_pills'; // StringList of identifiers e.g. ['web','image','tools']
// Chat input behavior
static const String _sendOnEnterKey = 'send_on_enter';
/// Get reduced motion preference
static Future<bool> getReduceMotion() async {
@@ -139,6 +141,7 @@ class SettingsService {
voiceAutoSendFinal: await getVoiceAutoSendFinal(),
socketTransportMode: await getSocketTransportMode(),
quickPills: await getQuickPills(),
sendOnEnter: await getSendOnEnter(),
);
}
@@ -158,6 +161,7 @@ class SettingsService {
setVoiceAutoSendFinal(settings.voiceAutoSendFinal),
setSocketTransportMode(settings.socketTransportMode),
setQuickPills(settings.quickPills),
setSendOnEnter(settings.sendOnEnter),
]);
}
@@ -223,6 +227,17 @@ class SettingsService {
await prefs.setStringList(_quickPillsKey, pills.take(2).toList());
}
// Chat input behavior
static Future<bool> getSendOnEnter() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_sendOnEnterKey) ?? false;
}
static Future<void> setSendOnEnter(bool value) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_sendOnEnterKey, value);
}
/// Get effective animation duration considering all settings
static Duration getEffectiveAnimationDuration(
BuildContext context,
@@ -278,6 +293,7 @@ class AppSettings {
final bool voiceAutoSendFinal;
final String socketTransportMode; // 'auto' or 'ws'
final List<String> quickPills; // e.g., ['web','image']
final bool sendOnEnter;
const AppSettings({
this.reduceMotion = false,
@@ -293,6 +309,7 @@ class AppSettings {
this.voiceAutoSendFinal = false,
this.socketTransportMode = 'ws',
this.quickPills = const [],
this.sendOnEnter = false,
});
AppSettings copyWith({
@@ -309,6 +326,7 @@ class AppSettings {
bool? voiceAutoSendFinal,
String? socketTransportMode,
List<String>? quickPills,
bool? sendOnEnter,
}) {
return AppSettings(
reduceMotion: reduceMotion ?? this.reduceMotion,
@@ -324,6 +342,7 @@ class AppSettings {
voiceAutoSendFinal: voiceAutoSendFinal ?? this.voiceAutoSendFinal,
socketTransportMode: socketTransportMode ?? this.socketTransportMode,
quickPills: quickPills ?? this.quickPills,
sendOnEnter: sendOnEnter ?? this.sendOnEnter,
);
}
@@ -342,6 +361,7 @@ class AppSettings {
other.voiceLocaleId == voiceLocaleId &&
other.voiceHoldToTalk == voiceHoldToTalk &&
other.voiceAutoSendFinal == voiceAutoSendFinal &&
other.sendOnEnter == sendOnEnter &&
_listEquals(other.quickPills, quickPills);
// socketTransportMode intentionally not included in == to avoid frequent rebuilds
}
@@ -361,6 +381,7 @@ class AppSettings {
voiceHoldToTalk,
voiceAutoSendFinal,
socketTransportMode,
sendOnEnter,
Object.hashAllUnordered(quickPills),
);
}
@@ -458,6 +479,11 @@ class AppSettingsNotifier extends StateNotifier<AppSettings> {
await SettingsService.setQuickPills(filtered);
}
Future<void> setSendOnEnter(bool value) async {
state = state.copyWith(sendOnEnter: value);
await SettingsService.setSendOnEnter(value);
}
Future<void> resetToDefaults() async {
const defaultSettings = AppSettings();
await SettingsService.saveSettings(defaultSettings);

View File

@@ -56,6 +56,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Timer? _scrollDebounceTimer;
bool _isDeactivated = false;
double _inputHeight = 0; // dynamic input height to position scroll button
bool _lastKeyboardVisible = false; // track keyboard visibility transitions
bool _didStartupFocus = false; // one-time auto-focus on startup
String _formatModelDisplayName(
String name, {
@@ -607,7 +609,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Widget _buildLoadingMessagesList() {
return ListView.builder(
key: const ValueKey('loading_messages'),
controller: _scrollController,
// Do not reuse the primary scroll controller here to avoid
// attaching the same controller to multiple lists during
// AnimatedSwitcher transitions.
controller: null,
padding: const EdgeInsets.fromLTRB(
Spacing.lg,
Spacing.md,
@@ -929,6 +934,21 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final canScroll = _scrollController.hasClients &&
_scrollController.position.maxScrollExtent > 0;
// On keyboard open, if already near bottom, auto-scroll to bottom to keep input visible
if (keyboardVisible && !_lastKeyboardVisible) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_scrollController.hasClients) {
final maxScroll = _scrollController.position.maxScrollExtent;
final currentScroll = _scrollController.position.pixels;
if (maxScroll - currentScroll < 300) {
_scrollToBottom(smooth: true);
}
}
});
}
_lastKeyboardVisible = keyboardVisible;
// Auto-select model when in reviewer mode with no selection
if (isReviewerMode && selectedModel == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -936,12 +956,36 @@ class _ChatPageState extends ConsumerState<ChatPage> {
});
}
// Focus composer on app startup once, when a model is selected
if (!_didStartupFocus && selectedModel != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final current = ref.read(inputFocusTriggerProvider);
// Immediate focus bump
ref.read(inputFocusTriggerProvider.notifier).state = current + 1;
// Second bump shortly after to overcome route/IME timing
Future.delayed(const Duration(milliseconds: 120), () {
if (!mounted) return;
final cur2 = ref.read(inputFocusTriggerProvider);
ref.read(inputFocusTriggerProvider.notifier).state = cur2 + 1;
});
});
_didStartupFocus = true;
}
return ErrorBoundary(
child: PopScope(
canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) return;
// First, if any input has focus, clear focus and consume back press
final currentFocus = FocusManager.instance.primaryFocus;
if (currentFocus != null && currentFocus.hasFocus) {
currentFocus.unfocus();
return;
}
// Auto-handle leaving without confirmation
final messages = ref.read(chatMessagesProvider);
final isStreaming = messages.any((msg) => msg.isStreaming);
@@ -1312,7 +1356,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
],
],
),
body: Stack(
body: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
FocusManager.instance.primaryFocus?.unfocus();
try {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
},
child: Stack(
children: [
Column(
children: [
@@ -1347,8 +1399,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
},
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () =>
FocusManager.instance.primaryFocus?.unfocus(),
onTap: () {
FocusManager.instance.primaryFocus?.unfocus();
try {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
},
child: RepaintBoundary(
child: _buildMessagesList(theme),
),
@@ -1464,6 +1520,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Edge overlay removed; rely on native interactive drawer drag
],
),
),
), // Scaffold
), // PopScope
); // ErrorBoundary

View File

@@ -21,6 +21,14 @@ import '../../chat/services/voice_input_service.dart';
import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart';
class _SendMessageIntent extends Intent {
const _SendMessageIntent();
}
class _InsertNewlineIntent extends Intent {
const _InsertNewlineIntent();
}
class ModernChatInput extends ConsumerStatefulWidget {
final Function(String) onSendMessage;
final bool enabled;
@@ -166,7 +174,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
late AnimationController _expandController;
late AnimationController _pulseController;
Timer? _blurCollapseTimer;
bool _hasAutoFocusedOnce = false;
bool _pendingFocusAfterExpand = false;
late VoiceInputService _voiceService;
StreamSubscription<int>? _intensitySub;
StreamSubscription<String>? _textSub;
@@ -185,6 +193,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
vsync: this,
value: 1.0, // Start expanded
);
_expandController.addStatusListener((status) {
if (!mounted || _isDeactivated) return;
if (_pendingFocusAfterExpand && status == AnimationStatus.completed) {
_pendingFocusAfterExpand = false;
// Focus and ensure IME shows reliably after expansion finishes
_ensureFocusedIfEnabled();
Future.microtask(() {
try {
if (_focusNode.hasFocus) {
SystemChannels.textInput.invokeMethod('TextInput.show');
}
} catch (_) {}
});
}
});
_pulseController = AnimationController(
duration: AnimationDuration.slow,
vsync: this,
@@ -230,6 +253,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (hasFocus) {
if (!_isExpanded) _setExpanded(true);
} else {
// A blur occurred: ensure no pending auto-focus remains
_pendingFocusAfterExpand = false;
// Defer collapse slightly to avoid IME show/hide race conditions
_blurCollapseTimer = Timer(const Duration(milliseconds: 160), () {
if (!mounted || _isDeactivated) return;
@@ -247,16 +272,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
});
});
// Let autofocus handle the focus - no manual intervention
// The TextField's autofocus: true should handle focus and keyboard automatically
// Additionally, request focus after first frame to ensure reliability across platforms
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
if (!_hasAutoFocusedOnce && widget.enabled) {
_ensureFocusedIfEnabled();
_hasAutoFocusedOnce = true;
}
});
// Do not auto-focus on mount; only focus on explicit user intent
}
@override
@@ -299,14 +315,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
@override
void didUpdateWidget(covariant ModernChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) {
// Became enabled (e.g., after selecting a model) → focus the input
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
_ensureFocusedIfEnabled();
_hasAutoFocusedOnce = true;
});
}
// Avoid auto-focusing when becoming enabled; wait for user intent
if (!widget.enabled && oldWidget.enabled) {
// Became disabled → collapse and hide keyboard
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -326,12 +335,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
PlatformUtils.lightHaptic();
widget.onSendMessage(text);
_controller.clear();
// After sending, dismiss keyboard and collapse input
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
// Ensure UI reflects empty state and collapses
_setExpanded(false);
// Keep focus and keyboard open; do not collapse automatically
}
void _setExpanded(bool expanded) {
@@ -346,6 +350,23 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}
}
void _insertNewline() {
final text = _controller.text;
TextSelection sel = _controller.selection;
final int start = sel.isValid ? sel.start : text.length;
final int end = sel.isValid ? sel.end : text.length;
final String before = text.substring(0, start);
final String after = text.substring(end);
final String updated = '$before\n$after';
_controller.value = TextEditingValue(
text: updated,
selection: TextSelection.collapsed(offset: before.length + 1),
composing: TextRange.empty,
);
// Ensure field stays focused
_ensureFocusedIfEnabled();
}
@override
Widget build(BuildContext context) {
// Listen for prefilled text changes safely from build
@@ -376,6 +397,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
final selectedQuickPills =
ref.watch(appSettingsProvider.select((s) => s.quickPills));
final sendOnEnter = ref.watch(appSettingsProvider.select((s) => s.sendOnEnter));
final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
data: (t) => t,
@@ -389,18 +411,22 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
orElse: () => false,
);
// React to external focus requests (e.g., from share prefill)
// React to external focus requests (e.g., from share prefill or startup)
final focusTick = ref.watch(inputFocusTriggerProvider);
if (focusTick != _lastHandledFocusTick) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
// Do not steal focus if another input currently has primary focus
final currentFocus = FocusManager.instance.primaryFocus;
final anotherHasFocus = currentFocus != null && currentFocus != _focusNode;
if (!anotherHasFocus) {
// Explicit request: always try to focus and show the keyboard
_ensureFocusedIfEnabled();
if (!_isExpanded) _setExpanded(true);
// Nudge the platform text input to show reliably on iOS/Android
Future.microtask(() {
try {
if (_focusNode.hasFocus) {
SystemChannels.textInput.invokeMethod('TextInput.show');
}
} catch (_) {}
});
_lastHandledFocusTick = focusTick;
});
}
@@ -468,6 +494,25 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
top: Spacing.inputPadding,
bottom: Spacing.inputPadding,
),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!_isExpanded && widget.enabled) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
if (!mounted) return;
if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
}
});
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
@@ -497,6 +542,33 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
hint: AppLocalizations.of(
context,
)!.messageInputHint,
child: Shortcuts(
shortcuts: () {
final map = <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): const _SendMessageIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _SendMessageIntent(),
};
if (sendOnEnter) {
map[LogicalKeySet(LogicalKeyboardKey.enter)] = const _SendMessageIntent();
map[LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter)] = const _InsertNewlineIntent();
}
return map;
}(),
child: Actions(
actions: <Type, Action<Intent>>{
_SendMessageIntent: CallbackAction<_SendMessageIntent>(
onInvoke: (intent) {
_sendMessage();
return null;
},
),
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
onInvoke: (intent) {
_insertNewline();
return null;
},
),
},
child: TextField(
controller: _controller,
focusNode: _focusNode,
@@ -506,8 +578,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
keyboardType: TextInputType.multiline,
textCapitalization:
TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
textInputAction: sendOnEnter
? TextInputAction.send
: TextInputAction.newline,
showCursor: true,
scrollPadding: const EdgeInsets.only(bottom: 80),
keyboardAppearance: Theme.of(context).brightness,
cursorColor:
context.conduitTheme.inputText,
style: AppTypography.chatMessageStyle
@@ -554,24 +630,40 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
isDense: true,
alignLabelWithHint: true,
),
// Removed onChanged setState to reduce rebuilds
onSubmitted: (_) => _sendMessage(),
// Send on Enter when enabled; otherwise keep newline behavior
onSubmitted: (_) {
if (sendOnEnter) _sendMessage();
},
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
// Fallback in case animation is skipped
WidgetsBinding.instance
.addPostFrameCallback((_) {
if (!mounted) return;
if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
}
});
} else {
_ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
}
},
),
),
),
),
),
if (!_isExpanded) ...[
const SizedBox(width: Spacing.sm),
// Primary action button (Send/Stop) when collapsed
@@ -584,6 +676,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
],
),
),
),
// Expanded bottom row with additional options
if (_isExpanded) ...[
@@ -1192,6 +1285,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
void _showAttachmentOptions() {
HapticFeedback.selectionClick();
final prevCanRequest = _focusNode.canRequestFocus;
_focusNode.canRequestFocus = false;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
@@ -1263,16 +1358,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
],
),
),
);
).whenComplete(() {
if (mounted) {
_focusNode.canRequestFocus = prevCanRequest;
}
});
}
void _showUnifiedToolsModal() {
HapticFeedback.selectionClick();
final prevCanRequest = _focusNode.canRequestFocus;
_focusNode.canRequestFocus = false;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (context) => const UnifiedToolsModal(),
);
).whenComplete(() {
if (mounted) {
_focusNode.canRequestFocus = prevCanRequest;
}
});
}
// --- Inline Voice Input ---

View File

@@ -387,6 +387,62 @@ class AppCustomizationPage extends ConsumerWidget {
},
),
const SizedBox(height: Spacing.lg),
// Chat input behavior
Text(
'Chat',
style: context.conduitTheme.headingSmall?.copyWith(
color: context.conduitTheme.textPrimary,
),
),
const SizedBox(height: Spacing.md),
ConduitCard(
padding: EdgeInsets.zero,
child: Column(
children: [
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary
.withValues(alpha: Alpha.highlight),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
Platform.isIOS
? CupertinoIcons.paperplane
: Icons.keyboard_return,
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
),
title: Text(
'Send on Enter',
style: context.conduitTheme.bodyLarge?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
'Enter sends (soft keyboard). Cmd/Ctrl+Enter also available',
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
trailing: Switch.adaptive(
value: settings.sendOnEnter,
onChanged: (v) =>
ref.read(appSettingsProvider.notifier).setSendOnEnter(v),
),
),
],
),
),
const SizedBox(height: Spacing.lg),
Text(
AppLocalizations.of(context)!.realtime,

View File

@@ -28,6 +28,7 @@ class OptimizedList<T> extends ConsumerStatefulWidget {
final bool addRepaintBoundaries;
final bool enablePagination;
final double paginationThreshold;
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
const OptimizedList({
super.key,
@@ -53,6 +54,7 @@ class OptimizedList<T> extends ConsumerStatefulWidget {
this.addRepaintBoundaries = true,
this.enablePagination = false,
this.paginationThreshold = 0.8,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
});
@override
@@ -142,6 +144,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
controller: _scrollController,
padding: widget.padding,
physics: widget.physics ?? const AlwaysScrollableScrollPhysics(),
keyboardDismissBehavior: widget.keyboardDismissBehavior,
shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,
@@ -163,6 +166,7 @@ class _OptimizedListState<T> extends ConsumerState<OptimizedList<T>> {
controller: _scrollController,
padding: widget.padding,
physics: widget.physics ?? const AlwaysScrollableScrollPhysics(),
keyboardDismissBehavior: widget.keyboardDismissBehavior,
shrinkWrap: widget.shrinkWrap,
scrollDirection: widget.scrollDirection,
reverse: widget.reverse,