feat: model and user avatars

This commit is contained in:
cogwheel0
2025-09-20 22:03:55 +05:30
parent b1b3e813a4
commit 8d89fd79b1
9 changed files with 650 additions and 113 deletions

View File

@@ -14,6 +14,7 @@ import '../../../core/auth/auth_state_manager.dart';
import '../providers/chat_providers.dart';
import '../../../core/utils/debug_logger.dart';
import '../../../core/utils/user_display_name.dart';
import '../../../core/utils/model_icon_utils.dart';
import '../widgets/modern_chat_input.dart';
import '../widgets/user_message_bubble.dart';
@@ -41,6 +42,7 @@ 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';
import '../../../shared/widgets/model_avatar.dart';
// Removed unused PlatformUtils import
import '../../../core/services/platform_service.dart' as ps;
import 'package:flutter/gestures.dart' show DragStartBehavior;
@@ -699,6 +701,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
return _buildEmptyState(Theme.of(context));
}
final apiService = ref.watch(apiServiceProvider);
return OptimizedList<ChatMessage>(
key: const ValueKey('actual_messages'),
scrollController: _scrollController,
@@ -717,6 +721,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Resolve a friendly model display name for message headers
String? displayModelName;
Model? matchedModel;
final rawModel = message.model;
if (rawModel != null && rawModel.isNotEmpty) {
final omitProvider = ref
@@ -730,6 +735,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final match = models.firstWhere(
(m) => m.id == rawModel || m.name == rawModel,
);
matchedModel = match;
displayModelName = _formatModelDisplayName(
match.name,
omitProvider: omitProvider,
@@ -750,6 +756,11 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}
}
final modelIconUrl = resolveModelIconUrlForModel(
apiService,
matchedModel,
);
// Wrap message in selection container if in selection mode
Widget messageWidget;
@@ -770,6 +781,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
message: message,
isStreaming: isStreaming,
modelName: displayModelName,
modelIconUrl: modelIconUrl,
onCopy: () => _copyMessage(message.content),
onRegenerate: () => _regenerateMessage(message),
);
@@ -917,7 +929,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
final l10n = AppLocalizations.of(context)!;
// Use select to watch only connectivity status to reduce rebuilds
final isOnline = ref.watch(isOnlineProvider.select((status) => status));
// Use select to watch only the selected model to reduce rebuilds
final selectedModel = ref.watch(
selectedModelProvider.select((model) => model),
@@ -1819,6 +1830,8 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> {
required bool isSelected,
required VoidCallback onTap,
}) {
final api = ref.watch(apiServiceProvider);
final iconUrl = resolveModelIconUrlForModel(api, model);
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
@@ -1852,21 +1865,7 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> {
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
Platform.isIOS ? CupertinoIcons.cube : Icons.psychology,
color: context.conduitTheme.buttonPrimary,
size: 16,
),
),
ModelAvatar(size: 32, imageUrl: iconUrl, label: model.name),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(

View File

@@ -14,11 +14,13 @@ import 'enhanced_image_attachment.dart';
import 'package:conduit/l10n/app_localizations.dart';
import 'enhanced_attachment.dart';
import 'package:conduit/shared/widgets/chat_action_button.dart';
import '../../../shared/widgets/model_avatar.dart';
class AssistantMessageWidget extends ConsumerStatefulWidget {
final dynamic message;
final bool isStreaming;
final String? modelName;
final String? modelIconUrl;
final VoidCallback? onCopy;
final VoidCallback? onRegenerate;
final VoidCallback? onLike;
@@ -29,6 +31,7 @@ class AssistantMessageWidget extends ConsumerStatefulWidget {
required this.message,
this.isStreaming = false,
this.modelName,
this.modelIconUrl,
this.onCopy,
this.onRegenerate,
this.onLike,
@@ -87,8 +90,9 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
_updateTypingIndicatorGate();
}
// Rebuild cached avatar if model name changes
if (oldWidget.modelName != widget.modelName) {
// Rebuild cached avatar if model name or icon changes
if (oldWidget.modelName != widget.modelName ||
oldWidget.modelIconUrl != widget.modelIconUrl) {
_buildCachedAvatar();
}
}
@@ -409,28 +413,36 @@ class _AssistantMessageWidgetState extends ConsumerState<AssistantMessageWidget>
}
void _buildCachedAvatar() {
_cachedAvatar = Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
final theme = context.conduitTheme;
final iconUrl = widget.modelIconUrl?.trim();
final hasIcon = iconUrl != null && iconUrl.isNotEmpty;
final Widget leading = hasIcon
? ModelAvatar(size: 20, imageUrl: iconUrl, label: widget.modelName)
: Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary,
color: theme.buttonPrimary,
borderRadius: BorderRadius.circular(AppBorderRadius.small),
),
child: Icon(
Icons.auto_awesome,
color: context.conduitTheme.buttonPrimaryText,
color: theme.buttonPrimaryText,
size: 12,
),
),
);
_cachedAvatar = Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
leading,
const SizedBox(width: Spacing.xs),
Text(
widget.modelName ?? 'Assistant',
style: TextStyle(
color: context.conduitTheme.textSecondary,
color: theme.textSecondary,
fontSize: AppTypography.bodySmall,
fontWeight: FontWeight.w500,
letterSpacing: 0.1,

View File

@@ -16,7 +16,12 @@ 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 '../../../core/utils/model_icon_utils.dart';
import '../../../core/utils/user_avatar_utils.dart';
import '../../../shared/utils/conversation_context_menu.dart';
import '../../../shared/widgets/user_avatar.dart';
import '../../../shared/widgets/model_avatar.dart';
import '../../../core/models/model.dart';
class ChatsDrawer extends ConsumerStatefulWidget {
const ChatsDrawer({super.key});
@@ -955,11 +960,41 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
(ref.watch(chat.isLoadingConversationProvider) == true);
final bool isPinned = conv.pinned == true;
Model? model;
final modelId = (conv.model is String && (conv.model as String).isNotEmpty)
? conv.model as String
: null;
if (modelId != null) {
final modelsAsync = ref.watch(modelsProvider);
model = modelsAsync.maybeWhen(
data: (models) {
for (final m in models) {
if (m.id == modelId) return m;
}
return null;
},
orElse: () => null,
);
}
final api = ref.watch(apiServiceProvider);
final modelIconUrl = resolveModelIconUrlForModel(api, model);
Widget? leading;
if (modelId != null) {
leading = ModelAvatar(
size: 28,
imageUrl: modelIconUrl,
label: model?.name ?? modelId,
);
}
final tile = _ConversationTile(
title: title,
pinned: isPinned,
selected: isActive,
isLoading: isLoadingSelected,
leading: leading,
onTap: _isLoadingConversation
? null
: () => _selectConversation(context, conv.id),
@@ -1180,6 +1215,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
);
final dynamic authUser = ref.watch(authUserProvider);
final user = userFromProfile ?? authUser;
final api = ref.watch(apiServiceProvider);
String initialFor(String name) {
if (name.isEmpty) return 'U';
@@ -1189,6 +1225,7 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
final displayName = deriveUserDisplayName(user);
final initial = initialFor(displayName);
final avatarUrl = resolveUserAvatarUrlForUser(api, user);
return Padding(
padding: const EdgeInsets.fromLTRB(Spacing.sm, 0, Spacing.sm, Spacing.sm),
child: Column(
@@ -1216,7 +1253,6 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
width: IconSize.xl,
height: IconSize.xl,
decoration: BoxDecoration(
color: theme.buttonPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(
AppBorderRadius.avatar,
),
@@ -1225,13 +1261,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
width: BorderWidth.thin,
),
),
alignment: Alignment.center,
child: Text(
initial,
style: AppTypography.bodyLargeStyle.copyWith(
color: theme.buttonPrimary,
fontWeight: FontWeight.w700,
),
clipBehavior: Clip.antiAlias,
child: UserAvatar(
size: IconSize.xl,
imageUrl: avatarUrl,
fallbackText: initial,
),
),
const SizedBox(width: Spacing.xs),
@@ -1332,6 +1366,7 @@ class _ConversationTileContent extends StatelessWidget {
final bool selected;
final bool isLoading;
final VoidCallback? onMorePressed;
final Widget? leading;
const _ConversationTileContent({
required this.title,
@@ -1339,6 +1374,7 @@ class _ConversationTileContent extends StatelessWidget {
required this.selected,
required this.isLoading,
this.onMorePressed,
this.leading,
});
@override
@@ -1407,6 +1443,14 @@ class _ConversationTileContent extends StatelessWidget {
return Row(
mainAxisSize: hasFiniteWidth ? MainAxisSize.max : MainAxisSize.min,
children: [
if (leading != null) ...[
SizedBox(
width: TouchTarget.listItem,
height: TouchTarget.listItem,
child: Center(child: leading!),
),
const SizedBox(width: Spacing.sm),
],
Flexible(
fit: textFit,
child: Text(
@@ -1429,6 +1473,7 @@ class _ConversationTile extends StatelessWidget {
final bool pinned;
final bool selected;
final bool isLoading;
final Widget? leading;
final VoidCallback? onTap;
final VoidCallback? onLongPress;
final VoidCallback? onMorePressed;
@@ -1438,6 +1483,7 @@ class _ConversationTile extends StatelessWidget {
required this.pinned,
required this.selected,
required this.isLoading,
this.leading,
required this.onTap,
this.onLongPress,
this.onMorePressed,
@@ -1504,6 +1550,7 @@ class _ConversationTile extends StatelessWidget {
selected: selected,
isLoading: isLoading,
onMorePressed: onMorePressed,
leading: leading,
),
),
),

View File

@@ -18,11 +18,18 @@ import '../../../core/providers/app_providers.dart';
import '../../auth/providers/unified_auth_providers.dart';
import '../../../core/services/settings_service.dart';
import '../../../core/models/model.dart';
import '../../../core/services/api_service.dart';
import '../../../core/models/user.dart' as models;
import 'dart:async';
import 'dart:io';
import '../../chat/views/chat_page_helpers.dart';
import 'app_customization_page.dart';
import '../../../shared/widgets/modal_safe_area.dart';
import '../../../core/utils/user_display_name.dart';
import '../../../core/utils/user_avatar_utils.dart';
import '../../../core/utils/model_icon_utils.dart';
import '../../../shared/widgets/user_avatar.dart';
import '../../../shared/widgets/model_avatar.dart';
/// Profile page (You tab) showing user info and main actions
/// Enhanced with production-grade design tokens for better cohesion
@@ -32,6 +39,7 @@ class ProfilePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider);
final api = ref.watch(apiServiceProvider);
return ErrorBoundary(
child: user.when(
@@ -70,7 +78,7 @@ class ProfilePage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Profile Header - Enhanced with better spacing and animations
_buildProfileHeader(userData)
_buildProfileHeader(context, userData, api)
.animate()
.fadeIn(duration: AnimationDuration.pageTransition)
.slideY(
@@ -171,48 +179,85 @@ class ProfilePage extends ConsumerWidget {
);
}
Widget _buildProfileHeader(dynamic user) {
return Builder(
builder: (context) => ConduitCard(
padding: const EdgeInsets.all(Spacing.cardPadding),
child: Row(
children: [
// Enhanced avatar with better sizing and shadows
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
boxShadow: ConduitShadows.card,
),
child: ConduitAvatar(
size: IconSize.avatar,
text: user?.name?.substring(0, 1) ?? 'U',
),
Widget _buildProfileHeader(
BuildContext context,
dynamic user,
ApiService? api,
) {
final displayName = deriveUserDisplayName(user);
final characters = displayName.characters;
final initial = characters.isNotEmpty
? characters.first.toUpperCase()
: 'U';
final avatarUrl = resolveUserAvatarUrlForUser(api, user);
String? extractEmail(dynamic source) {
if (source is models.User) {
return source.email;
}
if (source is Map) {
final value = source['email'];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
final nested = source['user'];
if (nested is Map) {
final nestedValue = nested['email'];
if (nestedValue is String && nestedValue.trim().isNotEmpty) {
return nestedValue.trim();
}
}
}
return null;
}
final email = extractEmail(user) ?? 'No email';
return ConduitCard(
padding: const EdgeInsets.all(Spacing.cardPadding),
child: Row(
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
boxShadow: ConduitShadows.card,
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user?.name ?? 'User',
style: context.conduitTheme.headingMedium?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Spacing.sm),
Text(
user?.email ?? 'No email',
style: context.conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
),
),
// Status badge removed per design update
],
),
child: UserAvatar(
size: IconSize.avatar,
imageUrl: avatarUrl,
fallbackText: initial,
),
],
),
),
const SizedBox(width: Spacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayName,
style:
context.conduitTheme.headingMedium?.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
) ??
TextStyle(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Spacing.sm),
Text(
email,
style:
context.conduitTheme.bodyMedium?.copyWith(
color: context.conduitTheme.textSecondary,
) ??
TextStyle(color: context.conduitTheme.textSecondary),
),
],
),
),
],
),
);
}
@@ -333,6 +378,7 @@ class ProfilePage extends ConsumerWidget {
Widget _buildDefaultModelTile(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsProvider);
final modelsAsync = ref.watch(modelsProvider);
final api = ref.watch(apiServiceProvider);
return modelsAsync.when(
data: (models) {
@@ -346,12 +392,23 @@ class ProfilePage extends ConsumerWidget {
),
);
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: Container(
final selectedModelExplicit = settings.defaultModel != null;
final modelIconUrl = selectedModelExplicit
? resolveModelIconUrlForModel(api, currentModel)
: null;
final modelLabel = selectedModelExplicit
? currentModel.name
: AppLocalizations.of(context)!.autoSelect;
Widget leading;
if (selectedModelExplicit) {
leading = ModelAvatar(
size: 32,
imageUrl: modelIconUrl,
label: currentModel.name,
);
} else {
leading = Container(
padding: const EdgeInsets.all(Spacing.sm),
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
@@ -361,13 +418,21 @@ class ProfilePage extends ConsumerWidget {
),
child: Icon(
UiUtils.platformIcon(
ios: CupertinoIcons.cube_box,
android: Icons.psychology,
ios: CupertinoIcons.wand_stars,
android: Icons.auto_awesome,
),
color: context.conduitTheme.buttonPrimary,
size: IconSize.medium,
),
);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: Spacing.listItemPadding,
vertical: Spacing.sm,
),
leading: leading,
title: Text(
AppLocalizations.of(context)!.defaultModel,
style: context.conduitTheme.bodyLarge?.copyWith(
@@ -376,9 +441,7 @@ class ProfilePage extends ConsumerWidget {
),
),
subtitle: Text(
settings.defaultModel != null
? currentModel.name
: AppLocalizations.of(context)!.autoSelect,
modelLabel,
style: context.conduitTheme.bodySmall?.copyWith(
color: context.conduitTheme.textSecondary,
),
@@ -943,6 +1006,28 @@ class _DefaultModelBottomSheetState
required bool isAutoSelect,
required VoidCallback onTap,
}) {
final api = ref.watch(apiServiceProvider);
final Widget leading;
if (isAutoSelect) {
leading = Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
Platform.isIOS ? CupertinoIcons.wand_stars : Icons.auto_awesome,
color: context.conduitTheme.buttonPrimary,
size: 16,
),
);
} else {
final iconUrl = resolveModelIconUrlForModel(api, model);
leading = ModelAvatar(size: 32, imageUrl: iconUrl, label: model.name);
}
return PressableScale(
onTap: onTap,
borderRadius: BorderRadius.circular(AppBorderRadius.md),
@@ -976,27 +1061,7 @@ class _DefaultModelBottomSheetState
),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: context.conduitTheme.buttonPrimary.withValues(
alpha: 0.15,
),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Icon(
isAutoSelect
? (Platform.isIOS
? CupertinoIcons.wand_stars
: Icons.auto_awesome)
: (Platform.isIOS
? CupertinoIcons.cube
: Icons.psychology),
color: context.conduitTheme.buttonPrimary,
size: 16,
),
),
leading,
const SizedBox(width: Spacing.md),
Expanded(
child: Column(