refactor: improve chat input and message list layout, enhance keyboard visibility handling

This commit is contained in:
cogwheel0
2025-08-25 16:31:08 +05:30
parent 0a09372c4a
commit 265c7026af
2 changed files with 342 additions and 333 deletions

View File

@@ -9,7 +9,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_animate/flutter_animate.dart';
import 'dart:io' show Platform, File; import 'dart:io' show Platform, File;
import 'dart:async'; import 'dart:async';
import 'dart:ui' show ImageFilter;
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../providers/chat_providers.dart'; import '../providers/chat_providers.dart';
import '../../../core/utils/debug_logger.dart'; import '../../../core/utils/debug_logger.dart';
@@ -941,6 +940,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Watch reviewer mode and auto-select model if needed // Watch reviewer mode and auto-select model if needed
final isReviewerMode = ref.watch(reviewerModeProvider); final isReviewerMode = ref.watch(reviewerModeProvider);
// Keyboard visibility
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// 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((_) {
@@ -1340,7 +1342,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => onTap: () =>
FocusManager.instance.primaryFocus?.unfocus(), FocusManager.instance.primaryFocus?.unfocus(),
child: _buildMessagesList(theme), child: RepaintBoundary(
child: _buildMessagesList(theme),
),
), ),
), ),
), ),
@@ -1352,17 +1356,19 @@ class _ChatPageState extends ConsumerState<ChatPage> {
const ChatOfflineOverlay(), const ChatOfflineOverlay(),
// Modern Input (root matches input background including safe area) // Modern Input (root matches input background including safe area)
ModernChatInput( RepaintBoundary(
enabled: child: ModernChatInput(
selectedModel != null && enabled:
(isOnline || ref.watch(reviewerModeProvider)), selectedModel != null &&
onSendMessage: (text) => (isOnline || ref.watch(reviewerModeProvider)),
_handleMessageSend(text, selectedModel), onSendMessage: (text) =>
onVoiceInput: null, _handleMessageSend(text, selectedModel),
onFileAttachment: _handleFileAttachment, onVoiceInput: null,
onImageAttachment: _handleImageAttachment, onFileAttachment: _handleFileAttachment,
onCameraCapture: () => onImageAttachment: _handleImageAttachment,
_handleImageAttachment(fromCamera: true), onCameraCapture: () =>
_handleImageAttachment(fromCamera: true),
),
), ),
], ],
), ),
@@ -1390,44 +1396,42 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}, },
child: child:
(_showScrollToBottom && (_showScrollToBottom &&
!keyboardVisible &&
ref.watch(chatMessagesProvider).isNotEmpty) ref.watch(chatMessagesProvider).isNotEmpty)
? ClipRRect( ? ClipRRect(
key: const ValueKey('scroll_to_bottom_visible'), key: const ValueKey('scroll_to_bottom_visible'),
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
AppBorderRadius.floatingButton, AppBorderRadius.floatingButton,
), ),
child: BackdropFilter( child: Container(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), decoration: BoxDecoration(
child: Container( color: context
decoration: BoxDecoration( .conduitTheme
color: context .surfaceContainerHighest
.conduitTheme .withValues(alpha: 0.75),
.surfaceContainerHighest border: Border.all(
.withValues(alpha: 0.75), color: context.conduitTheme.cardBorder
border: Border.all( .withValues(alpha: 0.3),
color: context.conduitTheme.cardBorder width: BorderWidth.regular,
.withValues(alpha: 0.3),
width: BorderWidth.regular,
),
borderRadius: BorderRadius.circular(
AppBorderRadius.floatingButton,
),
boxShadow: ConduitShadows.button,
), ),
child: SizedBox( borderRadius: BorderRadius.circular(
width: TouchTarget.button, AppBorderRadius.floatingButton,
height: TouchTarget.button, ),
child: IconButton( boxShadow: ConduitShadows.button,
onPressed: _scrollToBottom, ),
splashRadius: 24, child: SizedBox(
icon: Icon( width: TouchTarget.button,
Platform.isIOS height: TouchTarget.button,
? CupertinoIcons.arrow_down child: IconButton(
: Icons.keyboard_arrow_down, onPressed: _scrollToBottom,
size: IconSize.lg, splashRadius: 24,
color: context.conduitTheme.iconPrimary icon: Icon(
.withValues(alpha: 0.9), Platform.isIOS
), ? CupertinoIcons.arrow_down
: Icons.keyboard_arrow_down,
size: IconSize.lg,
color: context.conduitTheme.iconPrimary
.withValues(alpha: 0.9),
), ),
), ),
), ),

View File

@@ -231,7 +231,6 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
children: [ children: [
// Main input area with unified 2-row design // Main input area with unified 2-row design
Container( Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: context.conduitTheme.inputBackground, color: context.conduitTheme.inputBackground,
borderRadius: const BorderRadius.vertical( borderRadius: const BorderRadius.vertical(
@@ -269,137 +268,22 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const ClampingScrollPhysics(), physics: const ClampingScrollPhysics(),
child: Column( child: RepaintBoundary(
mainAxisSize: MainAxisSize.min, child: Column(
children: [ mainAxisSize: MainAxisSize.min,
// Collapsed/Expanded top row: text input with left/right buttons in collapsed children: [
Padding( // Collapsed/Expanded top row: text input with left/right buttons in collapsed
padding: const EdgeInsets.only( Padding(
left: Spacing.inputPadding,
right: Spacing.inputPadding,
top: Spacing.inputPadding,
bottom: Spacing.inputPadding,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (!_isExpanded) ...[
_buildRoundButton(
icon: Icons.add,
onTap: widget.enabled
? _showAttachmentOptions
: null,
tooltip: AppLocalizations.of(
context,
)!.addAttachment,
showBackground: false,
iconSize: IconSize.large + 2.0,
),
const SizedBox(width: Spacing.xs),
] else ...[
// When expanded, the left padding was reduced to move the plus button.
// Add back spacing so the text field aligns comfortably from the edge.
SizedBox(
width: Spacing.inputPadding - Spacing.xs,
),
],
// Text input expands to fill
Expanded(
child: Semantics(
textField: true,
label: AppLocalizations.of(
context,
)!.messageInputLabel,
hint: AppLocalizations.of(
context,
)!.messageInputHint,
child: TextField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled,
autofocus: false,
maxLines: _isExpanded ? null : 1,
keyboardType: TextInputType.multiline,
textCapitalization:
TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
showCursor: true,
cursorColor: context.conduitTheme.inputText,
style: AppTypography.chatMessageStyle
.copyWith(
color: context.conduitTheme.inputText,
),
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.messageHintText,
hintStyle: TextStyle(
color: context
.conduitTheme
.inputPlaceholder,
fontSize: AppTypography.bodyLarge,
fontWeight: _isRecording
? FontWeight.w500
: FontWeight.w400,
fontStyle: _isRecording
? FontStyle.italic
: FontStyle.normal,
),
// Ensure the text field background matches its parent container
// and does not use the global InputDecorationTheme fill
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
alignLabelWithHint: true,
),
// Removed onChanged setState to reduce rebuilds
onSubmitted: (_) => _sendMessage(),
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_setExpanded(true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
if (!mounted) return;
_ensureFocusedIfEnabled();
});
} else {
_ensureFocusedIfEnabled();
}
},
),
),
),
if (!_isExpanded) ...[
const SizedBox(width: Spacing.sm),
// Primary action button (Send/Stop) when collapsed
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
),
],
],
),
),
// Expanded bottom row with additional options
if (_isExpanded) ...[
Container(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
left: Spacing.inputPadding, left: Spacing.inputPadding,
right: Spacing.inputPadding, right: Spacing.inputPadding,
top: Spacing.inputPadding,
bottom: Spacing.inputPadding, bottom: Spacing.inputPadding,
), ),
child: FadeTransition( child: Row(
opacity: _expandController, crossAxisAlignment: CrossAxisAlignment.center,
child: Row( children: [
children: [ if (!_isExpanded) ...[
_buildRoundButton( _buildRoundButton(
icon: Icons.add, icon: Icons.add,
onTap: widget.enabled onTap: widget.enabled
@@ -412,197 +296,318 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
iconSize: IconSize.large + 2.0, iconSize: IconSize.large + 2.0,
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
// Quick pills: no scroll, clip text within fixed max width ] else ...[
Expanded( // When expanded, the left padding was reduced to move the plus button.
child: Row( // Add back spacing so the text field aligns comfortably from the edge.
children: [ SizedBox(
Expanded( width: Spacing.inputPadding - Spacing.xs,
flex: 2, ),
child: _buildPillButton( ],
icon: Platform.isIOS // Text input expands to fill
? CupertinoIcons.search Expanded(
: Icons.search, child: Semantics(
label: AppLocalizations.of( textField: true,
context, label: AppLocalizations.of(
)!.web, context,
isActive: webSearchEnabled, )!.messageInputLabel,
onTap: widget.enabled hint: AppLocalizations.of(
? () { context,
ref )!.messageInputHint,
.read( child: TextField(
webSearchEnabledProvider controller: _controller,
.notifier, focusNode: _focusNode,
) enabled: widget.enabled,
.state = autofocus: false,
!webSearchEnabled; maxLines: _isExpanded ? null : 1,
} keyboardType: TextInputType.multiline,
: null, textCapitalization:
TextCapitalization.sentences,
textInputAction: TextInputAction.newline,
showCursor: true,
cursorColor:
context.conduitTheme.inputText,
style: AppTypography.chatMessageStyle
.copyWith(
color:
context.conduitTheme.inputText,
), ),
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,
), ),
if (imageGenAvailable) ...[ // Ensure the text field background matches its parent container
const SizedBox(width: Spacing.xs), // and does not use the global InputDecorationTheme fill
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
alignLabelWithHint: true,
),
// Removed onChanged setState to reduce rebuilds
onSubmitted: (_) => _sendMessage(),
onTap: () {
if (!widget.enabled) return;
if (!_isExpanded) {
_setExpanded(true);
WidgetsBinding.instance
.addPostFrameCallback((_) {
if (!mounted) return;
_ensureFocusedIfEnabled();
});
} else {
_ensureFocusedIfEnabled();
}
},
),
),
),
if (!_isExpanded) ...[
const SizedBox(width: Spacing.sm),
// Primary action button (Send/Stop) when collapsed
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
),
],
],
),
),
// Expanded bottom row with additional options
if (_isExpanded) ...[
Container(
padding: const EdgeInsets.only(
left: Spacing.inputPadding,
right: Spacing.inputPadding,
bottom: Spacing.inputPadding,
),
child: FadeTransition(
opacity: _expandController,
child: Row(
children: [
_buildRoundButton(
icon: Icons.add,
onTap: widget.enabled
? _showAttachmentOptions
: null,
tooltip: AppLocalizations.of(
context,
)!.addAttachment,
showBackground: false,
iconSize: IconSize.large + 2.0,
),
const SizedBox(width: Spacing.xs),
// Quick pills: no scroll, clip text within fixed max width
Expanded(
child: Row(
children: [
Expanded( Expanded(
flex: 3, flex: 2,
child: _buildPillButton( child: _buildPillButton(
icon: Platform.isIOS icon: Platform.isIOS
? CupertinoIcons.photo ? CupertinoIcons.search
: Icons.image, : Icons.search,
label: AppLocalizations.of( label: AppLocalizations.of(
context, context,
)!.imageGen, )!.web,
isActive: imageGenEnabled, isActive: webSearchEnabled,
onTap: widget.enabled onTap: widget.enabled
? () { ? () {
ref ref
.read( .read(
imageGenerationEnabledProvider webSearchEnabledProvider
.notifier, .notifier,
) )
.state = .state =
!imageGenEnabled; !webSearchEnabled;
} }
: null, : null,
), ),
), ),
if (imageGenAvailable) ...[
const SizedBox(width: Spacing.xs),
Expanded(
flex: 3,
child: _buildPillButton(
icon: Platform.isIOS
? CupertinoIcons.photo
: Icons.image,
label: AppLocalizations.of(
context,
)!.imageGen,
isActive: imageGenEnabled,
onTap: widget.enabled
? () {
ref
.read(
imageGenerationEnabledProvider
.notifier,
)
.state =
!imageGenEnabled;
}
: null,
),
),
],
const SizedBox(width: Spacing.xs),
_buildRoundButton(
icon: Icons.more_horiz,
onTap: widget.enabled
? _showUnifiedToolsModal
: null,
tooltip: AppLocalizations.of(
context,
)!.tools,
isActive:
ref
.watch(
selectedToolIdsProvider,
)
.isNotEmpty ||
webSearchEnabled ||
imageGenEnabled,
),
], ],
),
),
const SizedBox(width: Spacing.xs),
// Mic + Send cluster pinned to the right
Row(
mainAxisSize: MainAxisSize.min,
children: [
// Microphone button: inline voice input toggle with animated intensity ring
Builder(
builder: (context) {
const double buttonSize =
TouchTarget.comfortable;
final double t = _isRecording
? (_intensity.clamp(0, 10) /
10.0)
: 0.0;
final double ringMaxExtra = 16.0;
final double ringSize =
buttonSize + (ringMaxExtra * t);
final double ringOpacity =
0.15 + (0.35 * t);
return SizedBox(
width: buttonSize,
height: buttonSize,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedContainer(
duration: const Duration(
milliseconds: 120,
),
width: ringSize,
height: ringSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context
.conduitTheme
.buttonPrimary
.withValues(
alpha: ringOpacity,
),
),
),
Transform.scale(
scale: _isRecording
? 1.0 +
(_intensity.clamp(
0,
10,
) /
200)
: 1.0,
child: _buildRoundButton(
icon: Platform.isIOS
? CupertinoIcons
.mic_fill
: Icons.mic,
onTap:
(widget.enabled &&
voiceAvailable)
? _toggleVoice
: null,
tooltip:
AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
),
],
),
);
},
),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
_buildRoundButton( // Primary action button (Send/Stop) when expanded
icon: Icons.more_horiz, _buildPrimaryButton(
onTap: widget.enabled _hasText,
? _showUnifiedToolsModal isGenerating,
: null, stopGeneration,
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)
const SizedBox(width: Spacing.xs), // ignore: dead_code
// Mic + Send cluster pinned to the right if (false) ...[
Row( const SizedBox(width: Spacing.sm),
mainAxisSize: MainAxisSize.min, _buildRoundButton(
children: [ icon: Icons.bug_report,
// Microphone button: inline voice input toggle with animated intensity ring onTap: widget.enabled
Builder( ? () async {
builder: (context) { final result =
const double buttonSize = await _voiceService
TouchTarget.comfortable; .testOnDeviceStt();
final double t = _isRecording if (context.mounted) {
? (_intensity.clamp(0, 10) / 10.0) ScaffoldMessenger.of(
: 0.0; context,
final double ringMaxExtra = 16.0; ).showSnackBar(
final double ringSize = SnackBar(
buttonSize + (ringMaxExtra * t); content: Text(
final double ringOpacity = 'STT Test: $result',
0.15 + (0.35 * t); ),
duration: const Duration(
return SizedBox( seconds: 5,
width: buttonSize, ),
height: buttonSize, ),
child: Stack( );
alignment: Alignment.center, }
children: [ }
AnimatedContainer( : null,
duration: const Duration( tooltip: 'Test On-Device STT',
milliseconds: 120,
),
width: ringSize,
height: ringSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context
.conduitTheme
.buttonPrimary
.withValues(
alpha: ringOpacity,
),
),
),
Transform.scale(
scale: _isRecording
? 1.0 +
(_intensity.clamp(
0,
10,
) /
200)
: 1.0,
child: _buildRoundButton(
icon: Platform.isIOS
? CupertinoIcons
.mic_fill
: Icons.mic,
onTap:
(widget.enabled &&
voiceAvailable)
? _toggleVoice
: null,
tooltip:
AppLocalizations.of(
context,
)!.voiceInput,
isActive: _isRecording,
),
),
],
),
);
},
),
const SizedBox(width: Spacing.xs),
// Primary action button (Send/Stop) when expanded
_buildPrimaryButton(
_hasText,
isGenerating,
stopGeneration,
), ),
], ],
), // removed duplicate send button; now only in right cluster
// 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',
),
], ],
// removed duplicate send button; now only in right cluster ),
],
), ),
), ),
), ],
], ],
], ),
), ),
), ),
), ),