feat: enter to send option and one tap to focus keyboard
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,8 +1356,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
body: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: () {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
try {
|
||||
SystemChannels.textInput.invokeMethod('TextInput.hide');
|
||||
} catch (_) {}
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
// Messages Area with pull-to-refresh
|
||||
@@ -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),
|
||||
),
|
||||
@@ -1462,7 +1518,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
),
|
||||
// Edge overlay removed; rely on native interactive drawer drag
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
), // Scaffold
|
||||
), // PopScope
|
||||
|
||||
@@ -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) {
|
||||
_ensureFocusedIfEnabled();
|
||||
if (!_isExpanded) _setExpanded(true);
|
||||
}
|
||||
// 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,12 +494,31 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
top: Spacing.inputPadding,
|
||||
bottom: Spacing.inputPadding,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (!_isExpanded) ...[
|
||||
_buildRoundButton(
|
||||
icon: Icons.add,
|
||||
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: [
|
||||
if (!_isExpanded) ...[
|
||||
_buildRoundButton(
|
||||
icon: Icons.add,
|
||||
onTap: widget.enabled
|
||||
? _showAttachmentOptions
|
||||
: null,
|
||||
@@ -497,79 +542,126 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
hint: AppLocalizations.of(
|
||||
context,
|
||||
)!.messageInputHint,
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
autofocus: false,
|
||||
maxLines: _isExpanded ? null : 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
textInputAction: TextInputAction.newline,
|
||||
showCursor: true,
|
||||
cursorColor:
|
||||
context.conduitTheme.inputText,
|
||||
style: AppTypography.chatMessageStyle
|
||||
.copyWith(
|
||||
color: _isRecording
|
||||
? context
|
||||
.conduitTheme
|
||||
.inputPlaceholder
|
||||
: context
|
||||
.conduitTheme
|
||||
.inputText,
|
||||
fontStyle: _isRecording
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
fontWeight: _isRecording
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
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;
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
// Removed onChanged setState to reduce rebuilds
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
_InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(
|
||||
onInvoke: (intent) {
|
||||
_insertNewline();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
autofocus: false,
|
||||
maxLines: _isExpanded ? null : 1,
|
||||
keyboardType: TextInputType.multiline,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
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
|
||||
.copyWith(
|
||||
color: _isRecording
|
||||
? context
|
||||
.conduitTheme
|
||||
.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: () {
|
||||
if (!widget.enabled) return;
|
||||
if (!_isExpanded) {
|
||||
_pendingFocusAfterExpand = true;
|
||||
_setExpanded(true);
|
||||
// Fallback in case animation is skipped
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_ensureFocusedIfEnabled();
|
||||
});
|
||||
if (!mounted) return;
|
||||
if (_pendingFocusAfterExpand) {
|
||||
_ensureFocusedIfEnabled();
|
||||
try {
|
||||
SystemChannels.textInput
|
||||
.invokeMethod('TextInput.show');
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_ensureFocusedIfEnabled();
|
||||
try {
|
||||
SystemChannels.textInput
|
||||
.invokeMethod('TextInput.show');
|
||||
} catch (_) {}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_isExpanded) ...[
|
||||
@@ -582,6 +674,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user