refactor: optimize chat input layout and enhance drawer functionality with loading indicators
This commit is contained in:
@@ -287,8 +287,10 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
tooltip: AppLocalizations.of(
|
tooltip: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.addAttachment,
|
)!.addAttachment,
|
||||||
|
showBackground: false,
|
||||||
|
iconSize: IconSize.large,
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.xs),
|
||||||
],
|
],
|
||||||
// Text input expands to fill
|
// Text input expands to fill
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -395,14 +397,16 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
tooltip: AppLocalizations.of(
|
tooltip: AppLocalizations.of(
|
||||||
context,
|
context,
|
||||||
)!.addAttachment,
|
)!.addAttachment,
|
||||||
|
showBackground: false,
|
||||||
|
iconSize: IconSize.large,
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.xs),
|
||||||
// Quick pills: no scroll, clip text within fixed max width
|
// Quick pills: no scroll, clip text within fixed max width
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Flexible(
|
Expanded(
|
||||||
fit: FlexFit.loose,
|
flex: 2,
|
||||||
child: _buildPillButton(
|
child: _buildPillButton(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
? CupertinoIcons.search
|
? CupertinoIcons.search
|
||||||
@@ -425,9 +429,9 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (imageGenAvailable) ...[
|
if (imageGenAvailable) ...[
|
||||||
const SizedBox(width: Spacing.sm),
|
const SizedBox(width: Spacing.xs),
|
||||||
Flexible(
|
Expanded(
|
||||||
fit: FlexFit.loose,
|
flex: 3,
|
||||||
child: _buildPillButton(
|
child: _buildPillButton(
|
||||||
icon: Platform.isIOS
|
icon: Platform.isIOS
|
||||||
? CupertinoIcons.photo
|
? CupertinoIcons.photo
|
||||||
@@ -450,92 +454,108 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
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.sm),
|
const SizedBox(width: Spacing.xs),
|
||||||
_buildRoundButton(
|
// Mic + Send cluster pinned to the right
|
||||||
icon: Icons.more_horiz,
|
Row(
|
||||||
onTap: widget.enabled
|
mainAxisSize: MainAxisSize.min,
|
||||||
? _showUnifiedToolsModal
|
children: [
|
||||||
: null,
|
// Microphone button: inline voice input toggle with animated intensity ring
|
||||||
tooltip: AppLocalizations.of(
|
Builder(
|
||||||
context,
|
builder: (context) {
|
||||||
)!.tools,
|
const double buttonSize =
|
||||||
isActive:
|
TouchTarget.comfortable;
|
||||||
ref
|
final double t = _isRecording
|
||||||
.watch(selectedToolIdsProvider)
|
? (_intensity.clamp(0, 10) / 10.0)
|
||||||
.isNotEmpty ||
|
: 0.0;
|
||||||
webSearchEnabled ||
|
final double ringMaxExtra = 16.0;
|
||||||
imageGenEnabled,
|
final double ringSize =
|
||||||
),
|
buttonSize + (ringMaxExtra * t);
|
||||||
const SizedBox(width: Spacing.sm),
|
final double ringOpacity =
|
||||||
// Microphone button: inline voice input toggle with animated intensity ring
|
0.15 + (0.35 * t);
|
||||||
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(
|
return SizedBox(
|
||||||
width: buttonSize,
|
width: buttonSize,
|
||||||
height: buttonSize,
|
height: buttonSize,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(
|
duration: const Duration(
|
||||||
milliseconds: 120,
|
milliseconds: 120,
|
||||||
),
|
),
|
||||||
width: ringSize,
|
width: ringSize,
|
||||||
height: ringSize,
|
height: ringSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: context
|
color: context
|
||||||
.conduitTheme
|
.conduitTheme
|
||||||
.buttonPrimary
|
.buttonPrimary
|
||||||
.withValues(
|
.withValues(
|
||||||
alpha: ringOpacity,
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Transform.scale(
|
);
|
||||||
scale: _isRecording
|
},
|
||||||
? 1.0 +
|
),
|
||||||
(_intensity.clamp(
|
const SizedBox(width: Spacing.xs),
|
||||||
0,
|
// Primary action button (Send/Stop) when expanded
|
||||||
10,
|
_buildPrimaryButton(
|
||||||
) /
|
_hasText,
|
||||||
200)
|
isGenerating,
|
||||||
: 1.0,
|
stopGeneration,
|
||||||
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.sm),
|
|
||||||
// Debug button for testing on-device STT (enable by changing false to true)
|
// Debug button for testing on-device STT (enable by changing false to true)
|
||||||
// ignore: dead_code
|
// ignore: dead_code
|
||||||
if (false) ...[
|
if (false) ...[
|
||||||
@@ -565,13 +585,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
tooltip: 'Test On-Device STT',
|
tooltip: 'Test On-Device STT',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(width: Spacing.sm),
|
// removed duplicate send button; now only in right cluster
|
||||||
// Primary action button (Send/Stop) when expanded
|
|
||||||
_buildPrimaryButton(
|
|
||||||
_hasText,
|
|
||||||
isGenerating,
|
|
||||||
stopGeneration,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -716,6 +730,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
String? tooltip,
|
String? tooltip,
|
||||||
bool isActive = false,
|
bool isActive = false,
|
||||||
bool showBackground = true,
|
bool showBackground = true,
|
||||||
|
double? iconSize,
|
||||||
}) {
|
}) {
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: tooltip ?? '',
|
message: tooltip ?? '',
|
||||||
@@ -760,7 +775,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: IconSize.medium,
|
size: iconSize ?? IconSize.medium,
|
||||||
color: widget.enabled
|
color: widget.enabled
|
||||||
? (isActive
|
? (isActive
|
||||||
? context.conduitTheme.textPrimary
|
? context.conduitTheme.textPrimary
|
||||||
@@ -814,18 +829,15 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
|
|||||||
boxShadow: null,
|
boxShadow: null,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: ConstrainedBox(
|
child: Text(
|
||||||
constraints: const BoxConstraints(maxWidth: 140),
|
label,
|
||||||
child: Text(
|
maxLines: 1,
|
||||||
label,
|
overflow: TextOverflow.ellipsis,
|
||||||
maxLines: 1,
|
softWrap: false,
|
||||||
overflow: TextOverflow.ellipsis,
|
style: AppTypography.labelStyle.copyWith(
|
||||||
softWrap: false,
|
color: isActive
|
||||||
style: AppTypography.labelStyle.copyWith(
|
? context.conduitTheme.buttonPrimary
|
||||||
color: isActive
|
: context.conduitTheme.textPrimary,
|
||||||
? context.conduitTheme.buttonPrimary
|
|
||||||
: context.conduitTheme.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../../shared/utils/ui_utils.dart';
|
|||||||
import '../../../core/auth/auth_state_manager.dart';
|
import '../../../core/auth/auth_state_manager.dart';
|
||||||
import 'package:conduit/l10n/app_localizations.dart';
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
import '../../../core/models/user.dart' as models;
|
import '../../../core/models/user.dart' as models;
|
||||||
|
import '../../../shared/widgets/skeleton_loader.dart';
|
||||||
|
|
||||||
class ChatsDrawer extends ConsumerStatefulWidget {
|
class ChatsDrawer extends ConsumerStatefulWidget {
|
||||||
const ChatsDrawer({super.key});
|
const ChatsDrawer({super.key});
|
||||||
@@ -30,6 +31,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
Timer? _debounce;
|
Timer? _debounce;
|
||||||
String _query = '';
|
String _query = '';
|
||||||
bool _isLoadingConversation = false;
|
bool _isLoadingConversation = false;
|
||||||
|
String? _pendingConversationId;
|
||||||
String? _dragHoverFolderId;
|
String? _dragHoverFolderId;
|
||||||
bool _isDragging = false;
|
bool _isDragging = false;
|
||||||
bool _draggingHasFolder = false;
|
bool _draggingHasFolder = false;
|
||||||
@@ -88,7 +90,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
? CupertinoIcons.bubble_left
|
? CupertinoIcons.bubble_left
|
||||||
: Icons.add_comment,
|
: Icons.add_comment,
|
||||||
color: theme.iconPrimary,
|
color: theme.iconPrimary,
|
||||||
size: IconSize.listItem,
|
size: IconSize.lg,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
chat.startNewChat(ref);
|
chat.startNewChat(ref);
|
||||||
@@ -96,8 +98,8 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
},
|
},
|
||||||
tooltip: AppLocalizations.of(context)!.newChat,
|
tooltip: AppLocalizations.of(context)!.newChat,
|
||||||
constraints: const BoxConstraints(
|
constraints: const BoxConstraints(
|
||||||
minWidth: TouchTarget.listItem,
|
minWidth: TouchTarget.comfortable,
|
||||||
minHeight: TouchTarget.listItem,
|
minHeight: TouchTarget.comfortable,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -272,8 +274,9 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
...convs.map(
|
...convs.map(
|
||||||
(c) => _buildTileFor(c, inFolder: true),
|
(c) => _buildTileFor(c, inFolder: true),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.xs),
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
@@ -659,7 +662,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
name,
|
name,
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
style: AppTypography.standard.copyWith(
|
||||||
color: theme.textPrimary,
|
color: theme.textPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -935,10 +938,14 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
final isActive = ref.watch(activeConversationProvider)?.id == conv.id;
|
final isActive = ref.watch(activeConversationProvider)?.id == conv.id;
|
||||||
final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat');
|
final title = conv.title?.isEmpty == true ? 'Chat' : (conv.title ?? 'Chat');
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
|
final bool isLoadingSelected =
|
||||||
|
(_pendingConversationId == conv.id) &&
|
||||||
|
(ref.watch(chat.isLoadingConversationProvider) == true);
|
||||||
final tile = _ConversationTile(
|
final tile = _ConversationTile(
|
||||||
title: title,
|
title: title,
|
||||||
pinned: conv.pinned == true,
|
pinned: conv.pinned == true,
|
||||||
selected: isActive,
|
selected: isActive,
|
||||||
|
isLoading: isLoadingSelected,
|
||||||
onTap: _isLoadingConversation
|
onTap: _isLoadingConversation
|
||||||
? null
|
? null
|
||||||
: () => _selectConversation(context, conv.id),
|
: () => _selectConversation(context, conv.id),
|
||||||
@@ -1095,6 +1102,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
try {
|
try {
|
||||||
// Mark global loading to show skeletons in chat
|
// Mark global loading to show skeletons in chat
|
||||||
ref.read(chat.isLoadingConversationProvider.notifier).state = true;
|
ref.read(chat.isLoadingConversationProvider.notifier).state = true;
|
||||||
|
_pendingConversationId = id;
|
||||||
|
|
||||||
final api = ref.read(apiServiceProvider);
|
final api = ref.read(apiServiceProvider);
|
||||||
if (api != null) {
|
if (api != null) {
|
||||||
@@ -1109,10 +1117,12 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
|
|
||||||
// Clear global loading before closing drawer
|
// Clear global loading before closing drawer
|
||||||
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
|
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
|
||||||
|
_pendingConversationId = null;
|
||||||
|
|
||||||
if (mounted) navigator.maybePop();
|
if (mounted) navigator.maybePop();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
|
ref.read(chat.isLoadingConversationProvider.notifier).state = false;
|
||||||
|
_pendingConversationId = null;
|
||||||
if (mounted) navigator.maybePop();
|
if (mounted) navigator.maybePop();
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoadingConversation = false);
|
if (mounted) setState(() => _isLoadingConversation = false);
|
||||||
@@ -1225,7 +1235,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
|
|||||||
displayName,
|
displayName,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
style: AppTypography.standard.copyWith(
|
||||||
color: theme.textPrimary,
|
color: theme.textPrimary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
),
|
),
|
||||||
@@ -1495,6 +1505,7 @@ class _ConversationTile extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final bool pinned;
|
final bool pinned;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final bool isLoading;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onLongPress;
|
final VoidCallback? onLongPress;
|
||||||
final VoidCallback? onMorePressed;
|
final VoidCallback? onMorePressed;
|
||||||
@@ -1503,6 +1514,7 @@ class _ConversationTile extends StatelessWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
required this.pinned,
|
required this.pinned,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
|
required this.isLoading,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
this.onLongPress,
|
this.onLongPress,
|
||||||
this.onMorePressed,
|
this.onMorePressed,
|
||||||
@@ -1524,7 +1536,7 @@ class _ConversationTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
onTap: onTap,
|
onTap: isLoading ? null : onTap,
|
||||||
onLongPress: onLongPress,
|
onLongPress: onLongPress,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -1545,7 +1557,18 @@ class _ConversationTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: Spacing.xs),
|
const SizedBox(width: Spacing.xs),
|
||||||
if (onMorePressed != null)
|
if (isLoading)
|
||||||
|
SizedBox(
|
||||||
|
width: 72,
|
||||||
|
height: TouchTarget.small,
|
||||||
|
child: SkeletonLoader(
|
||||||
|
width: 72,
|
||||||
|
height: TouchTarget.small,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.chip),
|
||||||
|
isCompact: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (onMorePressed != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
visualDensity: VisualDensity.compact,
|
visualDensity: VisualDensity.compact,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ class _UnifiedToolsModalState extends ConsumerState<UnifiedToolsModal> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.lg),
|
// Removed extra spacing between feature tiles and tools list
|
||||||
|
|
||||||
// All tools as selectable tiles (model selector style)
|
// All tools as selectable tiles (model selector style)
|
||||||
toolsAsync.when(
|
toolsAsync.when(
|
||||||
|
|||||||
@@ -177,7 +177,8 @@ class OfflineAwareButton extends ConsumerWidget {
|
|||||||
|
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: !enabled
|
message: !enabled
|
||||||
? (offlineTooltip ?? AppLocalizations.of(context)!.featureRequiresInternet)
|
? (offlineTooltip ??
|
||||||
|
AppLocalizations.of(context)!.featureRequiresInternet)
|
||||||
: '',
|
: '',
|
||||||
child: FilledButton(onPressed: enabled ? onPressed : null, child: child),
|
child: FilledButton(onPressed: enabled ? onPressed : null, child: child),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user