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 {
GeneratedPluginRegistrant.register(with: self)
// Setup background streaming handler with scene-safe rootViewController access
var controller: FlutterViewController?
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 {
// Setup background streaming handler using the plugin registry messenger
if let registrar = self.registrar(forPlugin: "BackgroundStreamingHandler") {
let channel = FlutterMethodChannel(
name: "conduit/background_streaming",
binaryMessenger: controller.binaryMessenger
binaryMessenger: registrar.messenger()
)
backgroundStreamingHandler = BackgroundStreamingHandler()

View File

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

View File

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