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'
|
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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user