refactor: chat input
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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,14 +1332,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
if (!_isSelectionMode) ...[
|
if (!_isSelectionMode) ...[
|
||||||
IconButton(
|
Padding(
|
||||||
icon: Icon(
|
padding: const EdgeInsets.only(right: Spacing.inputPadding),
|
||||||
Platform.isIOS ? CupertinoIcons.create : Icons.add_comment,
|
child: IconButton(
|
||||||
color: context.conduitTheme.textPrimary,
|
icon: Icon(
|
||||||
size: IconSize.appBar,
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.create
|
||||||
|
: Icons.add_comment,
|
||||||
|
color: context.conduitTheme.textPrimary,
|
||||||
|
size: IconSize.appBar,
|
||||||
|
),
|
||||||
|
onPressed: _handleNewChat,
|
||||||
|
tooltip: AppLocalizations.of(context)!.newChat,
|
||||||
),
|
),
|
||||||
onPressed: _handleNewChat,
|
|
||||||
tooltip: AppLocalizations.of(context)!.newChat,
|
|
||||||
),
|
),
|
||||||
] else ...[
|
] else ...[
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -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) {
|
_ensureFocusedIfEnabled();
|
||||||
_pendingFocusAfterExpand = true;
|
|
||||||
_setExpanded(true);
|
|
||||||
} else {
|
|
||||||
_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) {
|
_ensureFocusedIfEnabled();
|
||||||
_pendingFocusAfterExpand =
|
|
||||||
true;
|
|
||||||
_setExpanded(true);
|
|
||||||
WidgetsBinding.instance
|
|
||||||
.addPostFrameCallback((
|
|
||||||
_,
|
|
||||||
) {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (_pendingFocusAfterExpand) {
|
|
||||||
_ensureFocusedIfEnabled();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
_ensureFocusedIfEnabled();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -665,423 +566,377 @@ 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
|
Container(
|
||||||
if (_isExpanded) ...[
|
padding: const EdgeInsets.only(
|
||||||
Container(
|
left: Spacing.inputPadding,
|
||||||
padding: const EdgeInsets.only(
|
right: Spacing.inputPadding,
|
||||||
left: Spacing.inputPadding,
|
top: Spacing.xs,
|
||||||
right: Spacing.inputPadding,
|
bottom: Spacing.sm,
|
||||||
top: Spacing.xs,
|
),
|
||||||
bottom: Spacing.sm,
|
child: Row(
|
||||||
),
|
children: [
|
||||||
child: FadeTransition(
|
_buildRoundButton(
|
||||||
opacity: _expandController,
|
icon: Icons.add,
|
||||||
child: Row(
|
onTap: widget.enabled && !_isRecording
|
||||||
children: [
|
? _showAttachmentOptions
|
||||||
_buildRoundButton(
|
: null,
|
||||||
icon: Icons.add,
|
tooltip: AppLocalizations.of(
|
||||||
onTap: widget.enabled && !_isRecording
|
context,
|
||||||
? _showAttachmentOptions
|
)!.addAttachment,
|
||||||
: null,
|
showBackground: false,
|
||||||
tooltip: AppLocalizations.of(
|
iconSize: IconSize.large + 2.0,
|
||||||
context,
|
),
|
||||||
)!.addAttachment,
|
const SizedBox(width: Spacing.xs),
|
||||||
showBackground: false,
|
// Quick pills: expand to full text when space allows
|
||||||
iconSize: IconSize.large + 2.0,
|
Expanded(
|
||||||
),
|
child: LayoutBuilder(
|
||||||
const SizedBox(width: Spacing.xs),
|
builder: (context, constraints) {
|
||||||
// Quick pills: expand to full text when space allows
|
final double total = constraints.maxWidth;
|
||||||
Expanded(
|
final bool showImage =
|
||||||
child: LayoutBuilder(
|
imageGenAvailable &&
|
||||||
builder: (context, constraints) {
|
showImagePillPref;
|
||||||
final double total =
|
final bool showWeb = showWebPill;
|
||||||
constraints.maxWidth;
|
// Tools button is always shown
|
||||||
final bool showImage =
|
final double toolsWidth =
|
||||||
imageGenAvailable &&
|
TouchTarget.minimum;
|
||||||
showImagePillPref;
|
final double gapBeforeTools = Spacing.xs;
|
||||||
final bool showWeb = showWebPill;
|
|
||||||
// Tools button is always shown
|
|
||||||
final double toolsWidth =
|
|
||||||
TouchTarget.minimum;
|
|
||||||
final double gapBeforeTools =
|
|
||||||
Spacing.xs;
|
|
||||||
|
|
||||||
final double availableForPills = math
|
final double availableForPills = math.max(
|
||||||
.max(
|
0.0,
|
||||||
0.0,
|
total - toolsWidth - gapBeforeTools,
|
||||||
total -
|
);
|
||||||
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 =
|
||||||
Spacing.md * 2;
|
Spacing.md * 2;
|
||||||
|
|
||||||
for (final id in selectedQuickPills) {
|
for (final id in selectedQuickPills) {
|
||||||
if (id == 'web' && showWeb) {
|
if (id == 'web' && showWeb) {
|
||||||
final lbl = AppLocalizations.of(
|
final lbl = AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.web;
|
)!.web;
|
||||||
final tp = TextPainter(
|
final tp = TextPainter(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
text: lbl,
|
text: lbl,
|
||||||
style: textStyle,
|
style: textStyle,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
textDirection:
|
textDirection: Directionality.of(
|
||||||
Directionality.of(context),
|
context,
|
||||||
)..layout();
|
),
|
||||||
entries.add({
|
)..layout();
|
||||||
'id': id,
|
entries.add({
|
||||||
'label': lbl,
|
'id': id,
|
||||||
'width':
|
'label': lbl,
|
||||||
tp.width +
|
'width':
|
||||||
horizontalPadding,
|
tp.width + horizontalPadding,
|
||||||
'widgetBuilder': () => _buildPillButton(
|
'widgetBuilder': () => _buildPillButton(
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.search
|
|
||||||
: Icons.search,
|
|
||||||
label: lbl,
|
|
||||||
isActive: webSearchEnabled,
|
|
||||||
onTap:
|
|
||||||
widget.enabled &&
|
|
||||||
!_isRecording
|
|
||||||
? () {
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
webSearchEnabledProvider
|
|
||||||
.notifier,
|
|
||||||
)
|
|
||||||
.state =
|
|
||||||
!webSearchEnabled;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else if (id == 'image' &&
|
|
||||||
showImage) {
|
|
||||||
final lbl = AppLocalizations.of(
|
|
||||||
context,
|
|
||||||
)!.imageGen;
|
|
||||||
final tp = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: lbl,
|
|
||||||
style: textStyle,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
textDirection:
|
|
||||||
Directionality.of(context),
|
|
||||||
)..layout();
|
|
||||||
entries.add({
|
|
||||||
'id': id,
|
|
||||||
'label': lbl,
|
|
||||||
'width':
|
|
||||||
tp.width +
|
|
||||||
horizontalPadding,
|
|
||||||
'widgetBuilder': () => _buildPillButton(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.photo
|
|
||||||
: Icons.image,
|
|
||||||
label: lbl,
|
|
||||||
isActive: imageGenEnabled,
|
|
||||||
onTap:
|
|
||||||
widget.enabled &&
|
|
||||||
!_isRecording
|
|
||||||
? () {
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
imageGenerationEnabledProvider
|
|
||||||
.notifier,
|
|
||||||
)
|
|
||||||
.state =
|
|
||||||
!imageGenEnabled;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Tool ID from server
|
|
||||||
Tool? tool;
|
|
||||||
for (final t in availableTools) {
|
|
||||||
if (t.id == id) {
|
|
||||||
tool = t;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tool != null) {
|
|
||||||
final lbl = tool.name;
|
|
||||||
final tp = TextPainter(
|
|
||||||
text: TextSpan(
|
|
||||||
text: lbl,
|
|
||||||
style: textStyle,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
textDirection:
|
|
||||||
Directionality.of(
|
|
||||||
context,
|
|
||||||
),
|
|
||||||
)..layout();
|
|
||||||
final selectedIds = ref.watch(
|
|
||||||
selectedToolIdsProvider,
|
|
||||||
);
|
|
||||||
final isActive = selectedIds
|
|
||||||
.contains(id);
|
|
||||||
entries.add({
|
|
||||||
'id': id,
|
|
||||||
'label': lbl,
|
|
||||||
'width':
|
|
||||||
tp.width +
|
|
||||||
horizontalPadding,
|
|
||||||
'widgetBuilder': () => _buildPillButton(
|
|
||||||
icon: Platform.isIOS
|
|
||||||
? CupertinoIcons.wrench
|
|
||||||
: Icons.build,
|
|
||||||
label: lbl,
|
|
||||||
isActive: isActive,
|
|
||||||
onTap:
|
|
||||||
widget.enabled &&
|
|
||||||
!_isRecording
|
|
||||||
? () {
|
|
||||||
final current =
|
|
||||||
List<
|
|
||||||
String
|
|
||||||
>.from(
|
|
||||||
ref.read(
|
|
||||||
selectedToolIdsProvider,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (current
|
|
||||||
.contains(id)) {
|
|
||||||
current.remove(
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
current.add(id);
|
|
||||||
}
|
|
||||||
ref
|
|
||||||
.read(
|
|
||||||
selectedToolIdsProvider
|
|
||||||
.notifier,
|
|
||||||
)
|
|
||||||
.state =
|
|
||||||
current;
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build rowChildren according to measured widths and available space
|
|
||||||
final List<Widget> rowChildren = [];
|
|
||||||
if (entries.isEmpty) {
|
|
||||||
// no quick pills, will just show tools later
|
|
||||||
} else if (entries.length == 1) {
|
|
||||||
final e = entries.first;
|
|
||||||
final pill =
|
|
||||||
e['widgetBuilder']() as Widget;
|
|
||||||
final w = (e['width'] as double);
|
|
||||||
if (w <= availableForPills) {
|
|
||||||
rowChildren.add(pill);
|
|
||||||
} else {
|
|
||||||
rowChildren.add(
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: pill,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// up to 2 based on settings enforcement; if more, take first 2
|
|
||||||
final e1 = entries[0];
|
|
||||||
final e2 = entries[1];
|
|
||||||
final w1 = (e1['width'] as double);
|
|
||||||
final w2 = (e2['width'] as double);
|
|
||||||
const double gapBetweenPills =
|
|
||||||
Spacing.xs;
|
|
||||||
final combined =
|
|
||||||
w1 + gapBetweenPills + w2;
|
|
||||||
final pill1 =
|
|
||||||
e1['widgetBuilder']() as Widget;
|
|
||||||
final pill2 =
|
|
||||||
e2['widgetBuilder']() as Widget;
|
|
||||||
|
|
||||||
if (combined <= availableForPills) {
|
|
||||||
rowChildren
|
|
||||||
..add(pill1)
|
|
||||||
..add(
|
|
||||||
const SizedBox(
|
|
||||||
width: Spacing.xs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(pill2);
|
|
||||||
} else if (w1 < availableForPills) {
|
|
||||||
rowChildren
|
|
||||||
..add(pill1)
|
|
||||||
..add(
|
|
||||||
const SizedBox(
|
|
||||||
width: Spacing.xs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: pill2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (w2 < availableForPills) {
|
|
||||||
rowChildren
|
|
||||||
..add(
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: pill1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(
|
|
||||||
const SizedBox(
|
|
||||||
width: Spacing.xs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(pill2);
|
|
||||||
} else {
|
|
||||||
final int f1 = math.max(
|
|
||||||
1,
|
|
||||||
w1.round(),
|
|
||||||
);
|
|
||||||
final int f2 = math.max(
|
|
||||||
1,
|
|
||||||
w2.round(),
|
|
||||||
);
|
|
||||||
rowChildren
|
|
||||||
..add(
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
flex: f1,
|
|
||||||
child: pill1,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(
|
|
||||||
const SizedBox(
|
|
||||||
width: Spacing.xs,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
..add(
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
flex: f2,
|
|
||||||
child: pill2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append tools button at the end (always visible)
|
|
||||||
|
|
||||||
rowChildren.add(
|
|
||||||
_buildIconButton(
|
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
? CupertinoIcons.wrench
|
? CupertinoIcons.search
|
||||||
: Icons.build,
|
: Icons.search,
|
||||||
|
label: lbl,
|
||||||
|
isActive: webSearchEnabled,
|
||||||
onTap:
|
onTap:
|
||||||
widget.enabled &&
|
widget.enabled &&
|
||||||
!_isRecording
|
!_isRecording
|
||||||
? _showUnifiedToolsModal
|
? () {
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
webSearchEnabledProvider
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.state =
|
||||||
|
!webSearchEnabled;
|
||||||
|
}
|
||||||
: null,
|
: null,
|
||||||
tooltip: AppLocalizations.of(
|
),
|
||||||
|
});
|
||||||
|
} else if (id == 'image' && showImage) {
|
||||||
|
final lbl = AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.imageGen;
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: lbl,
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
textDirection: Directionality.of(
|
||||||
|
context,
|
||||||
|
),
|
||||||
|
)..layout();
|
||||||
|
entries.add({
|
||||||
|
'id': id,
|
||||||
|
'label': lbl,
|
||||||
|
'width':
|
||||||
|
tp.width + horizontalPadding,
|
||||||
|
'widgetBuilder': () => _buildPillButton(
|
||||||
|
icon: Platform.isIOS
|
||||||
|
? CupertinoIcons.photo
|
||||||
|
: Icons.image,
|
||||||
|
label: lbl,
|
||||||
|
isActive: imageGenEnabled,
|
||||||
|
onTap:
|
||||||
|
widget.enabled &&
|
||||||
|
!_isRecording
|
||||||
|
? () {
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
imageGenerationEnabledProvider
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.state =
|
||||||
|
!imageGenEnabled;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Tool ID from server
|
||||||
|
Tool? tool;
|
||||||
|
for (final t in availableTools) {
|
||||||
|
if (t.id == id) {
|
||||||
|
tool = t;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tool != null) {
|
||||||
|
final lbl = tool.name;
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: lbl,
|
||||||
|
style: textStyle,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
textDirection: Directionality.of(
|
||||||
context,
|
context,
|
||||||
)!.tools,
|
),
|
||||||
isActive:
|
)..layout();
|
||||||
ref
|
final selectedIds = ref.watch(
|
||||||
.watch(
|
selectedToolIdsProvider,
|
||||||
selectedToolIdsProvider,
|
);
|
||||||
)
|
final isActive = selectedIds
|
||||||
.isNotEmpty ||
|
.contains(id);
|
||||||
webSearchEnabled ||
|
entries.add({
|
||||||
imageGenEnabled,
|
'id': id,
|
||||||
|
'label': lbl,
|
||||||
|
'width':
|
||||||
|
tp.width + horizontalPadding,
|
||||||
|
'widgetBuilder': () => _buildPillButton(
|
||||||
|
icon: Platform.isIOS
|
||||||
|
? CupertinoIcons.wrench
|
||||||
|
: Icons.build,
|
||||||
|
label: lbl,
|
||||||
|
isActive: isActive,
|
||||||
|
onTap:
|
||||||
|
widget.enabled &&
|
||||||
|
!_isRecording
|
||||||
|
? () {
|
||||||
|
final current =
|
||||||
|
List<String>.from(
|
||||||
|
ref.read(
|
||||||
|
selectedToolIdsProvider,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (current.contains(
|
||||||
|
id,
|
||||||
|
)) {
|
||||||
|
current.remove(id);
|
||||||
|
} else {
|
||||||
|
current.add(id);
|
||||||
|
}
|
||||||
|
ref
|
||||||
|
.read(
|
||||||
|
selectedToolIdsProvider
|
||||||
|
.notifier,
|
||||||
|
)
|
||||||
|
.state =
|
||||||
|
current;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build rowChildren according to measured widths and available space
|
||||||
|
final List<Widget> rowChildren = [];
|
||||||
|
if (entries.isEmpty) {
|
||||||
|
// no quick pills, will just show tools later
|
||||||
|
} else if (entries.length == 1) {
|
||||||
|
final e = entries.first;
|
||||||
|
final pill =
|
||||||
|
e['widgetBuilder']() as Widget;
|
||||||
|
final w = (e['width'] as double);
|
||||||
|
if (w <= availableForPills) {
|
||||||
|
rowChildren.add(pill);
|
||||||
|
} else {
|
||||||
|
rowChildren.add(
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: pill,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// up to 2 based on settings enforcement; if more, take first 2
|
||||||
|
final e1 = entries[0];
|
||||||
|
final e2 = entries[1];
|
||||||
|
final w1 = (e1['width'] as double);
|
||||||
|
final w2 = (e2['width'] as double);
|
||||||
|
const double gapBetweenPills =
|
||||||
|
Spacing.xs;
|
||||||
|
final combined =
|
||||||
|
w1 + gapBetweenPills + w2;
|
||||||
|
final pill1 =
|
||||||
|
e1['widgetBuilder']() as Widget;
|
||||||
|
final pill2 =
|
||||||
|
e2['widgetBuilder']() as Widget;
|
||||||
|
|
||||||
return Row(children: rowChildren);
|
if (combined <= availableForPills) {
|
||||||
},
|
rowChildren
|
||||||
),
|
..add(pill1)
|
||||||
),
|
..add(
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.xs),
|
||||||
Row(
|
)
|
||||||
mainAxisSize: MainAxisSize.min,
|
..add(pill2);
|
||||||
children: [
|
} else if (w1 < availableForPills) {
|
||||||
if (voiceAvailable) ...[
|
rowChildren
|
||||||
_buildVoiceButton(voiceAvailable),
|
..add(pill1)
|
||||||
const SizedBox(width: Spacing.xs),
|
..add(
|
||||||
],
|
const SizedBox(width: Spacing.xs),
|
||||||
_buildPrimaryButton(
|
)
|
||||||
_hasText,
|
..add(
|
||||||
isGenerating,
|
Flexible(
|
||||||
stopGeneration,
|
fit: FlexFit.loose,
|
||||||
|
child: pill2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (w2 < availableForPills) {
|
||||||
|
rowChildren
|
||||||
|
..add(
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
child: pill1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
)
|
||||||
|
..add(pill2);
|
||||||
|
} else {
|
||||||
|
final int f1 = math.max(
|
||||||
|
1,
|
||||||
|
w1.round(),
|
||||||
|
);
|
||||||
|
final int f2 = math.max(
|
||||||
|
1,
|
||||||
|
w2.round(),
|
||||||
|
);
|
||||||
|
rowChildren
|
||||||
|
..add(
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
flex: f1,
|
||||||
|
child: pill1,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
)
|
||||||
|
..add(
|
||||||
|
Flexible(
|
||||||
|
fit: FlexFit.loose,
|
||||||
|
flex: f2,
|
||||||
|
child: pill2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append tools button at the end (always visible)
|
||||||
|
rowChildren.add(
|
||||||
|
_buildIconButton(
|
||||||
|
icon: Platform.isIOS
|
||||||
|
? CupertinoIcons.wrench
|
||||||
|
: Icons.build,
|
||||||
|
onTap: widget.enabled && !_isRecording
|
||||||
|
? _showUnifiedToolsModal
|
||||||
|
: null,
|
||||||
|
tooltip: AppLocalizations.of(
|
||||||
|
context,
|
||||||
|
)!.tools,
|
||||||
|
isActive:
|
||||||
|
ref
|
||||||
|
.watch(
|
||||||
|
selectedToolIdsProvider,
|
||||||
|
)
|
||||||
|
.isNotEmpty ||
|
||||||
|
webSearchEnabled ||
|
||||||
|
imageGenEnabled,
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
),
|
|
||||||
// Debug button for testing on-device STT (enable by changing false to true)
|
return Row(children: rowChildren);
|
||||||
// ignore: dead_code
|
},
|
||||||
if (false) ...[
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
),
|
||||||
_buildRoundButton(
|
const SizedBox(width: Spacing.sm),
|
||||||
icon: Icons.bug_report,
|
Row(
|
||||||
onTap: widget.enabled
|
mainAxisSize: MainAxisSize.min,
|
||||||
? () async {
|
children: [
|
||||||
final result =
|
if (voiceAvailable) ...[
|
||||||
await _voiceService
|
_buildVoiceButton(voiceAvailable),
|
||||||
.testOnDeviceStt();
|
const SizedBox(width: Spacing.xs),
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'STT Test: $result',
|
|
||||||
),
|
|
||||||
duration: const Duration(
|
|
||||||
seconds: 5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
tooltip: 'Test On-Device STT',
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
_buildPrimaryButton(
|
||||||
|
_hasText,
|
||||||
|
isGenerating,
|
||||||
|
stopGeneration,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
// Debug button for testing on-device STT (enable by changing false to true)
|
||||||
|
// ignore: dead_code
|
||||||
|
if (false) ...[
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
_buildRoundButton(
|
||||||
|
icon: Icons.bug_report,
|
||||||
|
onTap: widget.enabled
|
||||||
|
? () async {
|
||||||
|
final result = await _voiceService
|
||||||
|
.testOnDeviceStt();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'STT Test: $result',
|
||||||
|
),
|
||||||
|
duration: const Duration(
|
||||||
|
seconds: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
tooltip: 'Test On-Device STT',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user