refactor: chat input

This commit is contained in:
cogwheel0
2025-09-18 15:01:21 +05:30
parent ac12eca6b5
commit dea14dfdcf
3 changed files with 377 additions and 526 deletions

View File

@@ -145,19 +145,11 @@ class BackgroundStreamingHandler: NSObject {
) -> Bool { ) -> Bool {
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
// Setup background streaming handler with scene-safe rootViewController access // Setup background streaming handler using the plugin registry messenger
var controller: FlutterViewController? if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let root = scene.windows.first?.rootViewController as? FlutterViewController {
controller = root
} else if let legacy = window?.rootViewController as? FlutterViewController {
controller = legacy
}
if let controller {
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "conduit/background_streaming", name: "conduit/background_streaming",
binaryMessenger: controller.binaryMessenger binaryMessenger: registrar.messenger()
) )
backgroundStreamingHandler = BackgroundStreamingHandler() backgroundStreamingHandler = BackgroundStreamingHandler()

View File

@@ -901,7 +901,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
).animate().fadeIn(delay: const Duration(milliseconds: 150)), ).animate().fadeIn(delay: const Duration(milliseconds: 150)),
], ],
), ),
), ),
@@ -1042,7 +1041,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
Scaffold.of(ctx).openDrawer(); Scaffold.of(ctx).openDrawer();
}, },
child: Padding( child: Padding(
padding: const EdgeInsets.all(4.0), padding: const EdgeInsets.only(
left: Spacing.inputPadding,
),
child: Icon( child: Icon(
Platform.isIOS Platform.isIOS
? CupertinoIcons.line_horizontal_3 ? CupertinoIcons.line_horizontal_3
@@ -1331,15 +1332,20 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
actions: [ actions: [
if (!_isSelectionMode) ...[ if (!_isSelectionMode) ...[
IconButton( Padding(
padding: const EdgeInsets.only(right: Spacing.inputPadding),
child: IconButton(
icon: Icon( icon: Icon(
Platform.isIOS ? CupertinoIcons.create : Icons.add_comment, Platform.isIOS
? CupertinoIcons.create
: Icons.add_comment,
color: context.conduitTheme.textPrimary, color: context.conduitTheme.textPrimary,
size: IconSize.appBar, size: IconSize.appBar,
), ),
onPressed: _handleNewChat, onPressed: _handleNewChat,
tooltip: AppLocalizations.of(context)!.newChat, tooltip: AppLocalizations.of(context)!.newChat,
), ),
),
] else ...[ ] else ...[
IconButton( IconButton(
icon: Icon( icon: Icon(

View File

@@ -150,14 +150,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
final TextEditingController _controller = TextEditingController(); final TextEditingController _controller = TextEditingController();
final FocusNode _focusNode = FocusNode(); final FocusNode _focusNode = FocusNode();
bool _isRecording = false; bool _isRecording = false;
bool _isExpanded = true; // Start expanded for better UX
// final String _voiceInputText = ''; // final String _voiceInputText = '';
bool _hasText = false; // track locally without rebuilding on each keystroke bool _hasText = false; // track locally without rebuilding on each keystroke
StreamSubscription<String>? _voiceStreamSubscription; StreamSubscription<String>? _voiceStreamSubscription;
late AnimationController _expandController;
late AnimationController _pulseController;
Timer? _blurCollapseTimer;
bool _pendingFocusAfterExpand = false;
late VoiceInputService _voiceService; late VoiceInputService _voiceService;
StreamSubscription<int>? _intensitySub; StreamSubscription<int>? _intensitySub;
StreamSubscription<String>? _textSub; StreamSubscription<String>? _textSub;
@@ -170,29 +165,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
void initState() { void initState() {
super.initState(); super.initState();
_voiceService = ref.read(voiceInputServiceProvider); _voiceService = ref.read(voiceInputServiceProvider);
_expandController = AnimationController(
duration:
AnimationDuration.fast, // Faster animation for better responsiveness
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();
// Let platform show IME naturally when focus is active. Avoid manual
// TextInput.show here to prevent race conditions on Android.
// If a device/IME requires a nudge, the TextField's onTap path covers it.
}
});
_pulseController = AnimationController(
duration: AnimationDuration.slow,
vsync: this,
);
// Apply any prefilled text on first frame (focus/expand handled via inputFocusTrigger) // Apply any prefilled text on first frame (focus handled via inputFocusTrigger)
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
final text = ref.read(prefilledInputTextProvider); final text = ref.read(prefilledInputTextProvider);
@@ -213,19 +187,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
setState(() => _hasText = has); setState(() => _hasText = has);
// Intelligent expansion: expand when user starts typing
if (has && !_isExpanded) {
_setExpanded(true);
}
}); });
} }
}); });
// Intelligent expand/collapse around focus changes // Publish focus changes to listeners
_focusNode.addListener(() { _focusNode.addListener(() {
// Cancel any pending blur-driven collapse
_blurCollapseTimer?.cancel();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
final hasFocus = _focusNode.hasFocus; final hasFocus = _focusNode.hasFocus;
@@ -233,25 +200,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
try { try {
ref.read(composerHasFocusProvider.notifier).state = hasFocus; ref.read(composerHasFocusProvider.notifier).state = hasFocus;
} catch (_) {} } catch (_) {}
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;
if (_focusNode.hasFocus) return; // focus came back
// Collapse only when keyboard is fully hidden to avoid flicker
final keyboardVisible =
MediaQuery.of(context).viewInsets.bottom > 0;
if (keyboardVisible) return;
final has = _controller.text.trim().isNotEmpty;
if (!has && _isExpanded) {
_setExpanded(false);
}
});
}
}); });
}); });
@@ -265,9 +213,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} catch (_) {} } catch (_) {}
_controller.dispose(); _controller.dispose();
_focusNode.dispose(); _focusNode.dispose();
_expandController.dispose();
_pulseController.dispose();
_blurCollapseTimer?.cancel();
_voiceStreamSubscription?.cancel(); _voiceStreamSubscription?.cancel();
_intensitySub?.cancel(); _intensitySub?.cancel();
_textSub?.cancel(); _textSub?.cancel();
@@ -286,9 +231,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
@override @override
void deactivate() { void deactivate() {
_isDeactivated = true; _isDeactivated = true;
_blurCollapseTimer?.cancel();
_expandController.stop();
_pulseController.stop();
super.deactivate(); super.deactivate();
} }
@@ -303,10 +245,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Avoid auto-focusing when becoming enabled; wait for user intent // Avoid auto-focusing when becoming enabled; wait for user intent
if (!widget.enabled && oldWidget.enabled) { if (!widget.enabled && oldWidget.enabled) {
// Became disabled → collapse and hide keyboard
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
if (_isExpanded) _setExpanded(false);
if (_focusNode.hasFocus) { if (_focusNode.hasFocus) {
_focusNode.unfocus(); _focusNode.unfocus();
} }
@@ -324,18 +264,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Keep focus and keyboard open; do not collapse automatically // Keep focus and keyboard open; do not collapse automatically
} }
void _setExpanded(bool expanded) {
if (!mounted || _isDeactivated || _isExpanded == expanded) return;
setState(() {
_isExpanded = expanded;
});
if (expanded) {
_expandController.forward();
} else {
_expandController.reverse();
}
}
void _insertNewline() { void _insertNewline() {
final text = _controller.text; final text = _controller.text;
TextSelection sel = _controller.selection; TextSelection sel = _controller.selection;
@@ -408,7 +336,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (!mounted || _isDeactivated) return; if (!mounted || _isDeactivated) return;
// Explicit request: always try to focus and show the keyboard // Explicit request: always try to focus and show the keyboard
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
if (!_isExpanded) _setExpanded(true);
_lastHandledFocusTick = focusTick; _lastHandledFocusTick = focusTick;
}); });
} }
@@ -467,13 +394,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
.fast, // Faster for better responsiveness .fast, // Faster for better responsiveness
curve: Curves.fastOutSlowIn, // More efficient curve curve: Curves.fastOutSlowIn, // More efficient curve
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
onEnd: () {
if (!mounted || _isDeactivated) return;
if (_pendingFocusAfterExpand) {
_pendingFocusAfterExpand = false;
_ensureFocusedIfEnabled();
}
},
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
child: RepaintBoundary( child: RepaintBoundary(
@@ -485,7 +405,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
padding: const EdgeInsets.fromLTRB( padding: const EdgeInsets.fromLTRB(
Spacing.sm, Spacing.sm,
Spacing.sm, Spacing.sm,
Spacing.sm, Spacing.xs,
Spacing.sm, Spacing.sm,
), ),
child: Container( child: Container(
@@ -507,12 +427,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand = true;
_setExpanded(true);
} else {
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
}
}, },
child: Semantics( child: Semantics(
textField: true, textField: true,
@@ -573,7 +488,8 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
focusNode: _focusNode, focusNode: _focusNode,
enabled: widget.enabled, enabled: widget.enabled,
autofocus: false, autofocus: false,
maxLines: _isExpanded ? null : 1, minLines: 1,
maxLines: null,
keyboardType: keyboardType:
TextInputType.multiline, TextInputType.multiline,
textCapitalization: textCapitalization:
@@ -642,22 +558,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
}, },
onTap: () { onTap: () {
if (!widget.enabled) return; if (!widget.enabled) return;
if (!_isExpanded) {
_pendingFocusAfterExpand =
true;
_setExpanded(true);
WidgetsBinding.instance
.addPostFrameCallback((
_,
) {
if (!mounted) return;
if (_pendingFocusAfterExpand) {
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
}
});
} else {
_ensureFocusedIfEnabled();
}
}, },
), ),
), ),
@@ -665,29 +566,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
), ),
), ),
if (!_isExpanded) ...[
const SizedBox(width: Spacing.sm),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (voiceAvailable) ...[
_buildVoiceButton(voiceAvailable),
const SizedBox(width: Spacing.xs),
],
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
),
],
),
],
], ],
), ),
), ),
), ),
// Expanded bottom row with additional options
if (_isExpanded) ...[
Container( Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: Spacing.inputPadding, left: Spacing.inputPadding,
@@ -695,8 +577,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
top: Spacing.xs, top: Spacing.xs,
bottom: Spacing.sm, bottom: Spacing.sm,
), ),
child: FadeTransition(
opacity: _expandController,
child: Row( child: Row(
children: [ children: [
_buildRoundButton( _buildRoundButton(
@@ -715,8 +595,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
Expanded( Expanded(
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final double total = final double total = constraints.maxWidth;
constraints.maxWidth;
final bool showImage = final bool showImage =
imageGenAvailable && imageGenAvailable &&
showImagePillPref; showImagePillPref;
@@ -724,20 +603,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
// Tools button is always shown // Tools button is always shown
final double toolsWidth = final double toolsWidth =
TouchTarget.minimum; TouchTarget.minimum;
final double gapBeforeTools = final double gapBeforeTools = Spacing.xs;
Spacing.xs;
final double availableForPills = math final double availableForPills = math.max(
.max(
0.0, 0.0,
total - total - toolsWidth - gapBeforeTools,
toolsWidth -
gapBeforeTools,
); );
// Compose selected pill entries in order // Compose selected pill entries in order
final List<Map<String, dynamic>> final List<Map<String, dynamic>> entries =
entries = []; [];
final textStyle = final textStyle =
AppTypography.labelStyle; AppTypography.labelStyle;
const double horizontalPadding = const double horizontalPadding =
@@ -754,15 +629,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
style: textStyle, style: textStyle,
), ),
maxLines: 1, maxLines: 1,
textDirection: textDirection: Directionality.of(
Directionality.of(context), context,
),
)..layout(); )..layout();
entries.add({ entries.add({
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': 'width':
tp.width + tp.width + horizontalPadding,
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.search ? CupertinoIcons.search
@@ -784,8 +659,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
: null, : null,
), ),
}); });
} else if (id == 'image' && } else if (id == 'image' && showImage) {
showImage) {
final lbl = AppLocalizations.of( final lbl = AppLocalizations.of(
context, context,
)!.imageGen; )!.imageGen;
@@ -795,15 +669,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
style: textStyle, style: textStyle,
), ),
maxLines: 1, maxLines: 1,
textDirection: textDirection: Directionality.of(
Directionality.of(context), context,
),
)..layout(); )..layout();
entries.add({ entries.add({
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': 'width':
tp.width + tp.width + horizontalPadding,
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.photo ? CupertinoIcons.photo
@@ -842,8 +716,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
style: textStyle, style: textStyle,
), ),
maxLines: 1, maxLines: 1,
textDirection: textDirection: Directionality.of(
Directionality.of(
context, context,
), ),
)..layout(); )..layout();
@@ -856,8 +729,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
'id': id, 'id': id,
'label': lbl, 'label': lbl,
'width': 'width':
tp.width + tp.width + horizontalPadding,
horizontalPadding,
'widgetBuilder': () => _buildPillButton( 'widgetBuilder': () => _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.wrench ? CupertinoIcons.wrench
@@ -869,18 +741,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
!_isRecording !_isRecording
? () { ? () {
final current = final current =
List< List<String>.from(
String
>.from(
ref.read( ref.read(
selectedToolIdsProvider, selectedToolIdsProvider,
), ),
); );
if (current if (current.contains(
.contains(id)) {
current.remove(
id, id,
); )) {
current.remove(id);
} else { } else {
current.add(id); current.add(id);
} }
@@ -937,18 +806,14 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
rowChildren rowChildren
..add(pill1) ..add(pill1)
..add( ..add(
const SizedBox( const SizedBox(width: Spacing.xs),
width: Spacing.xs,
),
) )
..add(pill2); ..add(pill2);
} else if (w1 < availableForPills) { } else if (w1 < availableForPills) {
rowChildren rowChildren
..add(pill1) ..add(pill1)
..add( ..add(
const SizedBox( const SizedBox(width: Spacing.xs),
width: Spacing.xs,
),
) )
..add( ..add(
Flexible( Flexible(
@@ -965,9 +830,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
) )
..add( ..add(
const SizedBox( const SizedBox(width: Spacing.xs),
width: Spacing.xs,
),
) )
..add(pill2); ..add(pill2);
} else { } else {
@@ -988,9 +851,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
), ),
) )
..add( ..add(
const SizedBox( const SizedBox(width: Spacing.xs),
width: Spacing.xs,
),
) )
..add( ..add(
Flexible( Flexible(
@@ -1003,15 +864,12 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
} }
// Append tools button at the end (always visible) // Append tools button at the end (always visible)
rowChildren.add( rowChildren.add(
_buildIconButton( _buildIconButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.wrench ? CupertinoIcons.wrench
: Icons.build, : Icons.build,
onTap: onTap: widget.enabled && !_isRecording
widget.enabled &&
!_isRecording
? _showUnifiedToolsModal ? _showUnifiedToolsModal
: null, : null,
tooltip: AppLocalizations.of( tooltip: AppLocalizations.of(
@@ -1055,8 +913,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
icon: Icons.bug_report, icon: Icons.bug_report,
onTap: widget.enabled onTap: widget.enabled
? () async { ? () async {
final result = final result = await _voiceService
await _voiceService
.testOnDeviceStt(); .testOnDeviceStt();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
@@ -1080,8 +937,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
], ],
), ),
), ),
),
],
], ],
), ),
), ),
@@ -1539,7 +1394,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (mounted) { if (mounted) {
_focusNode.canRequestFocus = prevCanRequest; _focusNode.canRequestFocus = prevCanRequest;
if (wasFocused && widget.enabled) { if (wasFocused && widget.enabled) {
if (!_isExpanded) _setExpanded(true);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();
@@ -1568,7 +1422,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
if (mounted) { if (mounted) {
_focusNode.canRequestFocus = prevCanRequest; _focusNode.canRequestFocus = prevCanRequest;
if (wasFocused && widget.enabled) { if (wasFocused && widget.enabled) {
if (!_isExpanded) _setExpanded(true);
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
_ensureFocusedIfEnabled(); _ensureFocusedIfEnabled();