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

@@ -69,6 +69,48 @@ sealed class Model with _$Model {
?.map((e) => e.toString()) ?.map((e) => e.toString())
.toList(); .toList();
final baseMetadata = Map<String, dynamic>.from(
(json['metadata'] as Map<String, dynamic>?) ?? const {},
);
final metaSection = json['meta'] as Map<String, dynamic>?;
final infoSection = json['info'] as Map<String, dynamic>?;
String? profileImage = json['profile_image_url'] as String?;
profileImage ??= baseMetadata['profile_image_url'] as String?;
profileImage ??= metaSection?['profile_image_url'] as String?;
profileImage ??=
(infoSection?['meta'] as Map<String, dynamic>?)?['profile_image_url']
as String?;
final mergedMetadata = <String, dynamic>{
...baseMetadata,
if (json['canonical_slug'] != null)
'canonical_slug':
baseMetadata['canonical_slug'] ?? json['canonical_slug'],
if (json['created'] != null)
'created': baseMetadata['created'] ?? json['created'],
if (json['connection_type'] != null)
'connection_type':
baseMetadata['connection_type'] ?? json['connection_type'],
};
if (profileImage != null && profileImage.isNotEmpty) {
mergedMetadata['profile_image_url'] = profileImage;
}
if (metaSection != null) {
final existing =
(mergedMetadata['meta'] as Map<String, dynamic>?) ?? const {};
mergedMetadata['meta'] = {...existing, ...metaSection};
}
if (infoSection != null) {
final existingInfo =
(mergedMetadata['info'] as Map<String, dynamic>?) ?? const {};
mergedMetadata['info'] = {...existingInfo, ...infoSection};
}
return Model( return Model(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String, name: json['name'] as String,
@@ -83,11 +125,7 @@ sealed class Model with _$Model {
'context_length': json['context_length'], 'context_length': json['context_length'],
'supported_parameters': supportedParamsList ?? supportedParams, 'supported_parameters': supportedParamsList ?? supportedParams,
}, },
metadata: { metadata: mergedMetadata,
'canonical_slug': json['canonical_slug'],
'created': json['created'],
'connection_type': json['connection_type'],
},
); );
} }
} }

View File

@@ -0,0 +1,100 @@
import '../models/model.dart';
import '../services/api_service.dart';
String? deriveModelIcon(Model? model) {
if (model == null) return null;
String? pick(Map<String, dynamic>? source) {
if (source == null) return null;
for (final key in const [
'profile_image_url',
'profileImageUrl',
'profileImage',
'icon_url',
'icon',
'image',
'avatar',
]) {
final value = source[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
return null;
}
final metadata = model.metadata ?? const <String, dynamic>{};
final capabilities = model.capabilities ?? const <String, dynamic>{};
final info = metadata['info'] as Map<String, dynamic>?;
final infoMeta = info?['meta'] as Map<String, dynamic>?;
final nestedMeta = metadata['meta'] as Map<String, dynamic>?;
final candidates = <String?>[
pick(metadata),
pick(nestedMeta),
pick(info),
pick(infoMeta),
pick(capabilities),
pick(capabilities['meta'] as Map<String, dynamic>?),
];
for (final candidate in candidates) {
if (candidate != null && candidate.isNotEmpty) {
return candidate;
}
}
return null;
}
String? resolveModelIconUrl(ApiService? api, String? rawUrl) {
final value = rawUrl?.trim();
if (value == null || value.isEmpty) {
return null;
}
if (value.startsWith('data:image')) {
return value;
}
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
if (value.startsWith('//')) {
final base = api?.baseUrl;
if (base != null && base.isNotEmpty) {
try {
final baseUri = Uri.parse(base);
final scheme = baseUri.scheme.isNotEmpty ? baseUri.scheme : 'https';
return '$scheme:$value';
} catch (_) {
return 'https:$value';
}
}
return 'https:$value';
}
if (api == null || api.baseUrl.isEmpty) {
return value.startsWith('/') ? value : '/$value';
}
try {
final baseUri = Uri.parse(api.baseUrl);
final resolved = baseUri.resolve(value);
return resolved.toString();
} catch (_) {
final normalizedBase = api.baseUrl.endsWith('/')
? api.baseUrl.substring(0, api.baseUrl.length - 1)
: api.baseUrl;
if (value.startsWith('/')) {
return '$normalizedBase$value';
}
return '$normalizedBase/$value';
}
}
String? resolveModelIconUrlForModel(ApiService? api, Model? model) {
final raw = deriveModelIcon(model);
return resolveModelIconUrl(api, raw);
}

View File

@@ -0,0 +1,95 @@
import '../models/user.dart' as models;
import '../services/api_service.dart';
String? deriveUserProfileImage(dynamic user) {
if (user == null) return null;
String? pick(dynamic source) {
if (source is Map) {
for (final key in const [
'profile_image_url',
'profileImage',
'avatar_url',
'avatar',
'picture',
'image',
]) {
final value = source[key];
if (value is String && value.trim().isNotEmpty) {
return value.trim();
}
}
}
return null;
}
if (user is models.User) {
final value = user.profileImage;
if (value != null && value.trim().isNotEmpty) {
return value.trim();
}
return null;
}
final topLevel = pick(user);
if (topLevel != null) return topLevel;
if (user is Map && user['user'] != null) {
final nested = pick(user['user']);
if (nested != null) return nested;
}
return null;
}
String? resolveUserProfileImageUrl(ApiService? api, String? rawUrl) {
final value = rawUrl?.trim();
if (value == null || value.isEmpty) {
return null;
}
if (value.startsWith('data:image')) {
return value;
}
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
if (value.startsWith('//')) {
final base = api?.baseUrl;
if (base != null && base.isNotEmpty) {
try {
final baseUri = Uri.parse(base);
final scheme = baseUri.scheme.isNotEmpty ? baseUri.scheme : 'https';
return '$scheme:$value';
} catch (_) {
return 'https:$value';
}
}
return 'https:$value';
}
if (api == null || api.baseUrl.isEmpty) {
return value.startsWith('/') ? value : '/$value';
}
try {
final baseUri = Uri.parse(api.baseUrl);
final resolved = baseUri.resolve(value);
return resolved.toString();
} catch (_) {
final normalizedBase = api.baseUrl.endsWith('/')
? api.baseUrl.substring(0, api.baseUrl.length - 1)
: api.baseUrl;
if (value.startsWith('/')) {
return '$normalizedBase$value';
}
return '$normalizedBase/$value';
}
}
String? resolveUserAvatarUrlForUser(ApiService? api, dynamic user) {
final raw = deriveUserProfileImage(user);
return resolveUserProfileImageUrl(api, raw);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../theme/theme_extensions.dart';
import 'user_avatar.dart';
class ModelAvatar extends StatelessWidget {
final double size;
final String? imageUrl;
final String? label;
const ModelAvatar({super.key, required this.size, this.imageUrl, this.label});
@override
Widget build(BuildContext context) {
return AvatarImage(
size: size,
imageUrl: imageUrl,
borderRadius: BorderRadius.circular(AppBorderRadius.small),
fallbackBuilder: (context, size) {
final theme = context.conduitTheme;
String? uppercase;
final trimmed = label?.trim();
if (trimmed != null && trimmed.isNotEmpty) {
uppercase = trimmed.substring(0, 1).toUpperCase();
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: theme.buttonPrimary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(AppBorderRadius.small),
border: Border.all(
color: theme.buttonPrimary.withValues(alpha: 0.25),
width: BorderWidth.thin,
),
),
alignment: Alignment.center,
child: uppercase != null
? Text(
uppercase,
style: AppTypography.small.copyWith(
color: theme.buttonPrimary,
fontWeight: FontWeight.w600,
),
)
: Icon(
Icons.psychology,
color: theme.buttonPrimary,
size: size * 0.5,
),
);
},
);
}
}

View File

@@ -0,0 +1,125 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../services/brand_service.dart';
import '../theme/theme_extensions.dart';
typedef AvatarWidgetBuilder =
Widget Function(BuildContext context, double size);
class AvatarImage extends StatelessWidget {
final double size;
final String? imageUrl;
final BorderRadius? borderRadius;
final AvatarWidgetBuilder fallbackBuilder;
final AvatarWidgetBuilder? placeholderBuilder;
const AvatarImage({
super.key,
required this.size,
required this.fallbackBuilder,
this.imageUrl,
this.borderRadius,
this.placeholderBuilder,
});
BorderRadius get _radius => borderRadius ?? BorderRadius.circular(size / 2);
@override
Widget build(BuildContext context) {
final url = imageUrl?.trim();
if (url == null || url.isEmpty) {
return fallbackBuilder(context, size);
}
if (url.startsWith('data:image')) {
final content = _decodeDataImage(url);
if (content != null) {
return ClipRRect(
borderRadius: _radius,
child: Image.memory(
content,
width: size,
height: size,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
fallbackBuilder(context, size),
),
);
}
return fallbackBuilder(context, size);
}
return ClipRRect(
borderRadius: _radius,
child: CachedNetworkImage(
imageUrl: url,
width: size,
height: size,
fit: BoxFit.cover,
placeholder: (context, _) =>
(placeholderBuilder ?? _defaultPlaceholder)(context, size),
errorWidget: (context, url, error) => fallbackBuilder(context, size),
),
);
}
AvatarWidgetBuilder get _defaultPlaceholder => (context, size) {
return Container(
width: size,
height: size,
alignment: Alignment.center,
color: context.conduitTheme.surfaceContainer.withValues(alpha: 0.35),
child: SizedBox(
width: size * 0.35,
height: size * 0.35,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(
context.conduitTheme.buttonPrimary,
),
),
),
);
};
Uint8List? _decodeDataImage(String dataUrl) {
try {
final commaIndex = dataUrl.indexOf(',');
if (commaIndex == -1) return null;
final base64Data = dataUrl.substring(commaIndex + 1);
return base64Decode(base64Data);
} catch (_) {
return null;
}
}
}
class UserAvatar extends StatelessWidget {
final double size;
final String? imageUrl;
final String? fallbackText;
const UserAvatar({
super.key,
required this.size,
this.imageUrl,
this.fallbackText,
});
@override
Widget build(BuildContext context) {
return AvatarImage(
size: size,
imageUrl: imageUrl,
fallbackBuilder: (context, size) => BrandService.createBrandAvatar(
size: size,
fallbackText: fallbackText,
context: context,
),
);
}
}