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

View File

@@ -56,6 +56,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Timer? _scrollDebounceTimer; Timer? _scrollDebounceTimer;
bool _isDeactivated = false; bool _isDeactivated = false;
double _inputHeight = 0; // dynamic input height to position scroll button 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 _formatModelDisplayName(
String name, { String name, {
@@ -607,7 +609,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Widget _buildLoadingMessagesList() { Widget _buildLoadingMessagesList() {
return ListView.builder( return ListView.builder(
key: const ValueKey('loading_messages'), 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( padding: const EdgeInsets.fromLTRB(
Spacing.lg, Spacing.lg,
Spacing.md, Spacing.md,
@@ -929,6 +934,21 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final canScroll = _scrollController.hasClients && final canScroll = _scrollController.hasClients &&
_scrollController.position.maxScrollExtent > 0; _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 // Auto-select model when in reviewer mode with no selection
if (isReviewerMode && selectedModel == null) { if (isReviewerMode && selectedModel == null) {
WidgetsBinding.instance.addPostFrameCallback((_) { 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( return ErrorBoundary(
child: PopScope( child: PopScope(
canPop: false, canPop: false,
onPopInvokedWithResult: (bool didPop, Object? result) async { onPopInvokedWithResult: (bool didPop, Object? result) async {
if (didPop) return; 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 // Auto-handle leaving without confirmation
final messages = ref.read(chatMessagesProvider); final messages = ref.read(chatMessagesProvider);
final isStreaming = messages.any((msg) => msg.isStreaming); final isStreaming = messages.any((msg) => msg.isStreaming);
@@ -1312,8 +1356,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
], ],
], ],
), ),
body: Stack( body: GestureDetector(
children: [ behavior: HitTestBehavior.translucent,
onTap: () {
FocusManager.instance.primaryFocus?.unfocus();
try {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
},
child: Stack(
children: [
Column( Column(
children: [ children: [
// Messages Area with pull-to-refresh // Messages Area with pull-to-refresh
@@ -1347,8 +1399,12 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}, },
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => onTap: () {
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus();
try {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} catch (_) {}
},
child: RepaintBoundary( child: RepaintBoundary(
child: _buildMessagesList(theme), child: _buildMessagesList(theme),
), ),
@@ -1462,7 +1518,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
), ),
// Edge overlay removed; rely on native interactive drawer drag // Edge overlay removed; rely on native interactive drawer drag
], ],
),
), ),
), // Scaffold ), // Scaffold
), // PopScope ), // PopScope

View File

@@ -21,6 +21,14 @@ import '../../chat/services/voice_input_service.dart';
import '../../../shared/utils/platform_utils.dart'; import '../../../shared/utils/platform_utils.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
class _SendMessageIntent extends Intent {
const _SendMessageIntent();
}
class _InsertNewlineIntent extends Intent {
const _InsertNewlineIntent();
}
class ModernChatInput extends ConsumerStatefulWidget { class ModernChatInput extends ConsumerStatefulWidget {
final Function(String) onSendMessage; final Function(String) onSendMessage;
final bool enabled; final bool enabled;
@@ -166,7 +174,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
late AnimationController _expandController; late AnimationController _expandController;
late AnimationController _pulseController; late AnimationController _pulseController;
Timer? _blurCollapseTimer; Timer? _blurCollapseTimer;
bool _hasAutoFocusedOnce = false; bool _pendingFocusAfterExpand = false;
late VoiceInputService _voiceService; late VoiceInputService _voiceService;
StreamSubscription<int>? _intensitySub; StreamSubscription<int>? _intensitySub;
StreamSubscription<String>? _textSub; StreamSubscription<String>? _textSub;
@@ -185,6 +193,21 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
vsync: this, vsync: this,
value: 1.0, // Start expanded 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( _pulseController = AnimationController(
duration: AnimationDuration.slow, duration: AnimationDuration.slow,
vsync: this, vsync: this,
@@ -230,6 +253,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (hasFocus) { if (hasFocus) {
if (!_isExpanded) _setExpanded(true); if (!_isExpanded) _setExpanded(true);
} else { } else {
// A blur occurred: ensure no pending auto-focus remains
_pendingFocusAfterExpand = false;
// Defer collapse slightly to avoid IME show/hide race conditions // Defer collapse slightly to avoid IME show/hide race conditions
_blurCollapseTimer = Timer(const Duration(milliseconds: 160), () { _blurCollapseTimer = Timer(const Duration(milliseconds: 160), () {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
@@ -247,16 +272,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}); });
}); });
// Let autofocus handle the focus - no manual intervention // Do not auto-focus on mount; only focus on explicit user intent
// 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;
}
});
} }
@override @override
@@ -299,14 +315,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
@override @override
void didUpdateWidget(covariant ModernChatInput oldWidget) { void didUpdateWidget(covariant ModernChatInput oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.enabled && !oldWidget.enabled && !_hasAutoFocusedOnce) { // Avoid auto-focusing when becoming enabled; wait for user intent
// Became enabled (e.g., after selecting a model) → focus the input
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return;
_ensureFocusedIfEnabled();
_hasAutoFocusedOnce = true;
});
}
if (!widget.enabled && oldWidget.enabled) { if (!widget.enabled && oldWidget.enabled) {
// Became disabled → collapse and hide keyboard // Became disabled → collapse and hide keyboard
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -326,12 +335,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
PlatformUtils.lightHaptic(); PlatformUtils.lightHaptic();
widget.onSendMessage(text); widget.onSendMessage(text);
_controller.clear(); _controller.clear();
// After sending, dismiss keyboard and collapse input // Keep focus and keyboard open; do not collapse automatically
if (_focusNode.hasFocus) {
_focusNode.unfocus();
}
// Ensure UI reflects empty state and collapses
_setExpanded(false);
} }
void _setExpanded(bool expanded) { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Listen for prefilled text changes safely from build // Listen for prefilled text changes safely from build
@@ -376,6 +397,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final imageGenAvailable = ref.watch(imageGenerationAvailableProvider); final imageGenAvailable = ref.watch(imageGenerationAvailableProvider);
final selectedQuickPills = final selectedQuickPills =
ref.watch(appSettingsProvider.select((s) => s.quickPills)); ref.watch(appSettingsProvider.select((s) => s.quickPills));
final sendOnEnter = ref.watch(appSettingsProvider.select((s) => s.sendOnEnter));
final toolsAsync = ref.watch(toolsListProvider); final toolsAsync = ref.watch(toolsListProvider);
final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>( final List<Tool> availableTools = toolsAsync.maybeWhen<List<Tool>>(
data: (t) => t, data: (t) => t,
@@ -389,18 +411,22 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
orElse: () => false, 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); final focusTick = ref.watch(inputFocusTriggerProvider);
if (focusTick != _lastHandledFocusTick) { if (focusTick != _lastHandledFocusTick) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
// Do not steal focus if another input currently has primary focus // Explicit request: always try to focus and show the keyboard
final currentFocus = FocusManager.instance.primaryFocus; _ensureFocusedIfEnabled();
final anotherHasFocus = currentFocus != null && currentFocus != _focusNode; if (!_isExpanded) _setExpanded(true);
if (!anotherHasFocus) { // Nudge the platform text input to show reliably on iOS/Android
_ensureFocusedIfEnabled(); Future.microtask(() {
if (!_isExpanded) _setExpanded(true); try {
} if (_focusNode.hasFocus) {
SystemChannels.textInput.invokeMethod('TextInput.show');
}
} catch (_) {}
});
_lastHandledFocusTick = focusTick; _lastHandledFocusTick = focusTick;
}); });
} }
@@ -468,12 +494,31 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
top: Spacing.inputPadding, top: Spacing.inputPadding,
bottom: Spacing.inputPadding, bottom: Spacing.inputPadding,
), ),
child: Row( child: GestureDetector(
crossAxisAlignment: CrossAxisAlignment.center, behavior: HitTestBehavior.opaque,
children: [ onTap: () {
if (!_isExpanded) ...[ if (!_isExpanded && widget.enabled) {
_buildRoundButton( _pendingFocusAfterExpand = true;
icon: Icons.add, _setExpanded(true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
if (!mounted) return;
if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
}
});
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!_isExpanded) ...[
_buildRoundButton(
icon: Icons.add,
onTap: widget.enabled onTap: widget.enabled
? _showAttachmentOptions ? _showAttachmentOptions
: null, : null,
@@ -497,79 +542,126 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
hint: AppLocalizations.of( hint: AppLocalizations.of(
context, context,
)!.messageInputHint, )!.messageInputHint,
child: TextField( child: Shortcuts(
controller: _controller, shortcuts: () {
focusNode: _focusNode, final map = <LogicalKeySet, Intent>{
enabled: widget.enabled, LogicalKeySet(LogicalKeyboardKey.meta, LogicalKeyboardKey.enter): const _SendMessageIntent(),
autofocus: false, LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.enter): const _SendMessageIntent(),
maxLines: _isExpanded ? null : 1, };
keyboardType: TextInputType.multiline, if (sendOnEnter) {
textCapitalization: map[LogicalKeySet(LogicalKeyboardKey.enter)] = const _SendMessageIntent();
TextCapitalization.sentences, map[LogicalKeySet(LogicalKeyboardKey.shift, LogicalKeyboardKey.enter)] = const _InsertNewlineIntent();
textInputAction: TextInputAction.newline, }
showCursor: true, return map;
cursorColor: }(),
context.conduitTheme.inputText, child: Actions(
style: AppTypography.chatMessageStyle actions: <Type, Action<Intent>>{
.copyWith( _SendMessageIntent: CallbackAction<_SendMessageIntent>(
color: _isRecording onInvoke: (intent) {
? context _sendMessage();
.conduitTheme return null;
.inputPlaceholder },
: context
.conduitTheme
.inputText,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
), ),
decoration: InputDecoration( _InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
hintText: AppLocalizations.of( onInvoke: (intent) {
context, _insertNewline();
)!.messageHintText, return null;
hintStyle: TextStyle( },
color: context ),
.conduitTheme },
.inputPlaceholder, child: TextField(
fontSize: AppTypography.bodyLarge, controller: _controller,
fontWeight: _isRecording focusNode: _focusNode,
? FontWeight.w500 enabled: widget.enabled,
: FontWeight.w400, autofocus: false,
fontStyle: _isRecording maxLines: _isExpanded ? null : 1,
? FontStyle.italic keyboardType: TextInputType.multiline,
: FontStyle.normal, textCapitalization:
), TextCapitalization.sentences,
// Ensure the text field background matches its parent container textInputAction: sendOnEnter
// and does not use the global InputDecorationTheme fill ? TextInputAction.send
filled: false, : TextInputAction.newline,
border: InputBorder.none, showCursor: true,
enabledBorder: InputBorder.none, scrollPadding: const EdgeInsets.only(bottom: 80),
focusedBorder: InputBorder.none, keyboardAppearance: Theme.of(context).brightness,
errorBorder: InputBorder.none, cursorColor:
disabledBorder: InputBorder.none, context.conduitTheme.inputText,
contentPadding: EdgeInsets.zero, style: AppTypography.chatMessageStyle
isDense: true, .copyWith(
alignLabelWithHint: true, color: _isRecording
), ? context
// Removed onChanged setState to reduce rebuilds .conduitTheme
onSubmitted: (_) => _sendMessage(), .inputPlaceholder
: context
.conduitTheme
.inputText,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.messageHintText,
hintStyle: TextStyle(
color: context
.conduitTheme
.inputPlaceholder,
fontSize: AppTypography.bodyLarge,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
),
// Ensure the text field background matches its parent container
// and does not use the global InputDecorationTheme fill
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
alignLabelWithHint: true,
),
// Send on Enter when enabled; otherwise keep newline behavior
onSubmitted: (_) {
if (sendOnEnter) _sendMessage();
},
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
if (!_isExpanded) { if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_setExpanded(true); _setExpanded(true);
// Fallback in case animation is skipped
WidgetsBinding.instance WidgetsBinding.instance
.addPostFrameCallback((_) { .addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_ensureFocusedIfEnabled(); if (_pendingFocusAfterExpand) {
}); _ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
}
});
} else { } else {
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
try {
SystemChannels.textInput
.invokeMethod('TextInput.show');
} catch (_) {}
} }
}, },
), ),
),
),
), ),
), ),
if (!_isExpanded) ...[ if (!_isExpanded) ...[
@@ -582,6 +674,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
], ],
], ],
),
), ),
), ),
@@ -1192,6 +1285,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
void _showAttachmentOptions() { void _showAttachmentOptions() {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final prevCanRequest = _focusNode.canRequestFocus;
_focusNode.canRequestFocus = false;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
@@ -1263,16 +1358,26 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
], ],
), ),
), ),
); ).whenComplete(() {
if (mounted) {
_focusNode.canRequestFocus = prevCanRequest;
}
});
} }
void _showUnifiedToolsModal() { void _showUnifiedToolsModal() {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
final prevCanRequest = _focusNode.canRequestFocus;
_focusNode.canRequestFocus = false;
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
builder: (context) => const UnifiedToolsModal(), builder: (context) => const UnifiedToolsModal(),
); ).whenComplete(() {
if (mounted) {
_focusNode.canRequestFocus = prevCanRequest;
}
});
} }
// --- Inline Voice Input --- // --- 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), const SizedBox(height: Spacing.lg),
Text( Text(
AppLocalizations.of(context)!.realtime, AppLocalizations.of(context)!.realtime,

View File

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