feat: title on the header

This commit is contained in:
cogwheel0
2025-09-19 23:35:46 +05:30
parent c6efb53e3f
commit 1fc1cf9739
3 changed files with 412 additions and 314 deletions

View File

@@ -40,6 +40,7 @@ import '../../../shared/widgets/conduit_components.dart';
import '../../../shared/widgets/middle_ellipsis_text.dart';
import '../../../shared/widgets/modal_safe_area.dart';
import '../../../core/services/settings_service.dart';
import '../../../shared/utils/conversation_context_menu.dart';
// Removed unused PlatformUtils import
import '../../../core/services/platform_service.dart' as ps;
import 'package:flutter/gestures.dart' show DragStartBehavior;
@@ -913,6 +914,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context)!;
// Use select to watch only connectivity status to reduce rebuilds
final isOnline = ref.watch(isOnlineProvider.select((status) => status));
@@ -924,6 +926,28 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Watch reviewer mode and auto-select model if needed
final isReviewerMode = ref.watch(reviewerModeProvider);
final omitProviderInModelName = ref.watch(
appSettingsProvider.select(
(settings) => settings.omitProviderInModelName,
),
);
final conversationTitle = ref.watch(
activeConversationProvider.select((conv) => conv?.title),
);
final displayConversationTitle = (() {
final trimmed = conversationTitle?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
return trimmed;
}
return l10n.newChat;
})();
final formattedModelName = selectedModel != null
? _formatModelDisplayName(
selectedModel.name,
omitProvider: omitProviderInModelName,
)
: null;
// Keyboard visibility
final keyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0;
// Whether the messages list can actually scroll (avoids showing button when not needed)
@@ -1023,7 +1047,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
elevation: Elevation.none,
surfaceTintColor: Colors.transparent,
shadowColor: Colors.transparent,
toolbarHeight: kToolbarHeight - 8,
toolbarHeight: kToolbarHeight + 8,
centerTitle: true,
titleSpacing: 0.0,
leading: _isSelectionMode
@@ -1071,13 +1095,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
(models) => _showModelDropdown(context, ref, models),
);
},
onLongPress: () {
final conversation = ref.read(activeConversationProvider);
if (conversation == null) return;
showConversationContextMenu(
context: context,
ref: ref,
conversation: conversation,
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MiddleEllipsisText(
displayConversationTitle,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.3,
),
textAlign: TextAlign.center,
semanticsLabel: displayConversationTitle,
),
const SizedBox(height: Spacing.xs),
Transform.translate(
offset: const Offset(0, 0),
child: SizedBox(
height: 28,
height: 24,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
@@ -1114,28 +1160,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
const SizedBox(width: Spacing.xs),
Flexible(
child: Builder(
builder: (context) {
final omitProvider = ref
.watch(appSettingsProvider)
.omitProviderInModelName;
final label = _formatModelDisplayName(
selectedModel.name,
omitProvider: omitProvider,
);
return MiddleEllipsisText(
label,
style: AppTypography.headlineSmallStyle
.copyWith(
color: context
.conduitTheme
.textPrimary,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
semanticsLabel: label,
);
},
child: MiddleEllipsisText(
formattedModelName!,
style: AppTypography.small.copyWith(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
height: 1.2,
),
textAlign: TextAlign.center,
semanticsLabel: formattedModelName,
),
),
const SizedBox(width: Spacing.xs),
@@ -1169,7 +1202,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
if (ref.watch(reviewerModeProvider))
if (isReviewerMode)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Container(
@@ -1210,13 +1243,35 @@ class _ChatPageState extends ConsumerState<ChatPage> {
(models) => _showModelDropdown(context, ref, models),
);
},
onLongPress: () {
final conversation = ref.read(activeConversationProvider);
if (conversation == null) return;
showConversationContextMenu(
context: context,
ref: ref,
conversation: conversation,
);
},
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
MiddleEllipsisText(
displayConversationTitle,
style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
fontSize: 18,
height: 1.3,
),
textAlign: TextAlign.center,
semanticsLabel: displayConversationTitle,
),
const SizedBox(height: Spacing.xs),
Transform.translate(
offset: const Offset(0, 0),
child: SizedBox(
height: 28,
height: 24,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
@@ -1253,17 +1308,15 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
const SizedBox(width: Spacing.xs),
Flexible(
child: Text(
'Choose Model',
style: AppTypography.headlineSmallStyle
.copyWith(
color:
context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: MiddleEllipsisText(
l10n.chooseModel,
style: AppTypography.small.copyWith(
color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600,
height: 1.2,
),
textAlign: TextAlign.center,
semanticsLabel: l10n.chooseModel,
),
),
const SizedBox(width: Spacing.xs),
@@ -1297,7 +1350,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
),
),
),
if (ref.watch(reviewerModeProvider))
if (isReviewerMode)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Container(

View File

@@ -8,7 +8,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/providers/app_providers.dart';
import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/widgets/modal_safe_area.dart';
import '../../chat/providers/chat_providers.dart' as chat;
// import '../../files/views/files_page.dart';
import '../../profile/views/profile_page.dart';
@@ -17,6 +16,7 @@ import '../../../shared/widgets/themed_dialogs.dart';
import '../../../core/auth/auth_state_manager.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../../../core/utils/user_display_name.dart';
import '../../../shared/utils/conversation_context_menu.dart';
class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key});
@@ -798,62 +798,31 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
String folderId,
String folderName,
) {
final theme = context.conduitTheme;
// Ensure consistent modal padding/insets across the app
// ignore: unnecessary_import
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
showConduitContextMenu(
context: context,
backgroundColor: theme.surfaceBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.lg),
actions: [
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.pencil,
materialIcon: Icons.edit_rounded,
label: l10n.rename,
onBeforeClose: () => HapticFeedback.selectionClick(),
onSelected: () async {
await _renameFolder(context, folderId, folderName);
},
),
),
builder: (sheetContext) {
return ModalSheetSafeArea(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.modalPadding,
vertical: Spacing.modalPadding,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
color: theme.iconPrimary,
),
title: Text(
AppLocalizations.of(context)!.rename,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.selectionClick();
Navigator.pop(sheetContext);
await _renameFolder(context, folderId, folderName);
},
),
const Divider(height: 1),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
color: theme.error,
),
title: Text(
AppLocalizations.of(context)!.delete,
style: TextStyle(color: theme.error),
),
onTap: () async {
HapticFeedback.mediumImpact();
Navigator.pop(sheetContext);
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
],
),
);
},
ConduitContextMenuAction(
cupertinoIcon: CupertinoIcons.delete,
materialIcon: Icons.delete_rounded,
label: l10n.delete,
destructive: true,
onBeforeClose: () => HapticFeedback.mediumImpact(),
onSelected: () async {
await _confirmAndDeleteFolder(context, folderId, folderName);
},
),
],
);
}
@@ -1017,8 +986,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
: () => _selectConversation(context, conv.id),
onLongPress: null,
onMorePressed: () {
HapticFeedback.selectionClick();
_showConversationContextMenu(context, conv);
showConversationContextMenu(
context: context,
ref: ref,
conversation: conv,
);
},
);
@@ -1321,209 +1293,6 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
),
);
}
void _showConversationContextMenu(BuildContext context, dynamic conv) {
final theme = context.conduitTheme;
final bool isPinned = conv.pinned == true;
final bool isArchived = conv.archived == true;
HapticFeedback.selectionClick();
showModalBottomSheet(
context: context,
backgroundColor: theme.surfaceBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppBorderRadius.lg),
),
),
builder: (sheetContext) {
return ModalSheetSafeArea(
padding: const EdgeInsets.symmetric(
horizontal: Spacing.modalPadding,
vertical: Spacing.modalPadding,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
isPinned
? (Platform.isIOS
? CupertinoIcons.pin_slash
: Icons.push_pin_outlined)
: (Platform.isIOS
? CupertinoIcons.pin_fill
: Icons.push_pin_rounded),
color: theme.iconPrimary,
),
title: Text(
isPinned
? AppLocalizations.of(context)!.unpin
: AppLocalizations.of(context)!.pin,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
final pinErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdatePin;
try {
await chat.pinConversation(ref, conv.id, !isPinned);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(
this.context,
pinErrorMessage,
isError: true,
);
}
},
),
ListTile(
leading: Icon(
isArchived
? (Platform.isIOS
? CupertinoIcons.archivebox_fill
: Icons.unarchive_rounded)
: (Platform.isIOS
? CupertinoIcons.archivebox
: Icons.archive_rounded),
color: theme.iconPrimary,
),
title: Text(
isArchived
? AppLocalizations.of(context)!.unarchive
: AppLocalizations.of(context)!.archive,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.lightImpact();
Navigator.pop(sheetContext);
final archiveErrorMessage = AppLocalizations.of(
context,
)!.failedToUpdateArchive;
try {
await chat.archiveConversation(ref, conv.id, !isArchived);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(
this.context,
archiveErrorMessage,
isError: true,
);
}
},
),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.pencil : Icons.edit_rounded,
color: theme.iconPrimary,
),
title: Text(
AppLocalizations.of(context)!.rename,
style: TextStyle(color: theme.textPrimary),
),
onTap: () async {
HapticFeedback.selectionClick();
Navigator.pop(sheetContext);
await _renameConversation(context, conv.id, conv.title ?? '');
},
),
const Divider(height: 1),
ListTile(
leading: Icon(
Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded,
color: theme.error,
),
title: Text(
AppLocalizations.of(context)!.delete,
style: TextStyle(color: theme.error),
),
onTap: () async {
HapticFeedback.mediumImpact();
Navigator.pop(sheetContext);
await _confirmAndDeleteConversation(context, conv.id);
},
),
],
),
);
},
);
}
Future<void> _renameConversation(
BuildContext context,
String conversationId,
String currentTitle,
) async {
final l10n = AppLocalizations.of(context)!;
final newName = await ThemedDialogs.promptTextInput(
context,
title: l10n.renameChat,
hintText: l10n.enterChatName,
initialValue: currentTitle,
confirmText: l10n.save,
cancelText: l10n.cancel,
);
if (!mounted) return;
if (newName == null) return;
if (newName.isEmpty || newName == currentTitle) return;
final renameError = l10n.failedToRenameChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick();
// Reflect changes
ref.invalidate(conversationsProvider);
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = active!.copyWith(
title: newName,
);
}
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, renameError, isError: true);
}
}
Future<void> _confirmAndDeleteConversation(
BuildContext context,
String conversationId,
) async {
final l10n = AppLocalizations.of(context)!;
final confirmed = await ThemedDialogs.confirm(
context,
title: l10n.deleteChatTitle,
message: l10n.deleteChatMessage,
confirmText: l10n.delete,
isDestructive: true,
);
if (!mounted) return;
if (!confirmed) return;
final deleteError = l10n.failedToDeleteChat;
try {
final api = ref.read(apiServiceProvider);
if (api == null) throw Exception('No API service');
await api.deleteConversation(conversationId);
HapticFeedback.mediumImpact();
// Clear if deleting active
final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) {
ref.read(activeConversationProvider.notifier).state = null;
ref.read(chat.chatMessagesProvider.notifier).clearMessages();
}
ref.invalidate(conversationsProvider);
} catch (_) {
if (!mounted) return;
UiUtils.showMessage(this.context, deleteError, isError: true);
}
}
}
class _DragConversationData {
@@ -1596,7 +1365,7 @@ class _ConversationTileContent extends StatelessWidget {
final theme = context.conduitTheme;
final textStyle = AppTypography.standard.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w400,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
height: 1.4,
);
@@ -1696,15 +1465,22 @@ class _ConversationTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final borderColor = selected
? theme.navigationSelected
: pinned
? theme.navigationSelected.withValues(alpha: 0.30)
: theme.surfaceContainerHighest.withValues(alpha: 0.40);
final backgroundColor = theme.surfaceContainer;
final highlightColor = theme.navigationSelectedBackground.withValues(
alpha: 0.45,
);
final brightness = Theme.of(context).brightness;
final borderRadius = BorderRadius.circular(AppBorderRadius.navigation);
final Color background = selected
? theme.buttonPrimary.withValues(
alpha: brightness == Brightness.dark ? 0.28 : 0.16,
)
: theme.surfaceContainer;
final Color borderColor;
if (selected) {
borderColor = theme.buttonPrimary.withValues(alpha: 0.7);
} else if (pinned) {
borderColor = theme.buttonPrimary.withValues(alpha: 0.35);
} else {
borderColor = theme.surfaceContainerHighest.withValues(alpha: 0.40);
}
final List<BoxShadow> shadow = selected ? ConduitShadows.low : const [];
Color? overlayForStates(Set<WidgetState> states) {
if (states.contains(WidgetState.pressed)) {
@@ -1721,13 +1497,10 @@ class _ConversationTile extends StatelessWidget {
selected: selected,
button: true,
child: Material(
color: backgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
side: BorderSide(color: borderColor, width: BorderWidth.thin),
),
color: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: borderRadius),
child: InkWell(
borderRadius: BorderRadius.zero,
borderRadius: borderRadius,
onTap: isLoading ? null : onTap,
onLongPress: onLongPress,
overlayColor: WidgetStateProperty.resolveWith(overlayForStates),
@@ -1735,8 +1508,10 @@ class _ConversationTile extends StatelessWidget {
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
decoration: BoxDecoration(
color: selected ? highlightColor : Colors.transparent,
borderRadius: BorderRadius.zero,
color: background,
borderRadius: borderRadius,
border: Border.all(color: borderColor, width: BorderWidth.thin),
boxShadow: shadow,
),
child: ConstrainedBox(
constraints: const BoxConstraints(