From 8d89fd79b1518d9a16dfa20ac3ccddb14ab50b90 Mon Sep 17 00:00:00 2001 From: cogwheel0 <172976095+cogwheel0@users.noreply.github.com> Date: Sat, 20 Sep 2025 22:03:55 +0530 Subject: [PATCH] feat: model and user avatars --- lib/core/models/model.dart | 48 +++- lib/core/utils/model_icon_utils.dart | 100 +++++++++ lib/core/utils/user_avatar_utils.dart | 95 ++++++++ lib/features/chat/views/chat_page.dart | 31 ++- .../widgets/assistant_message_widget.dart | 34 ++- .../navigation/widgets/chats_drawer.dart | 63 +++++- lib/features/profile/views/profile_page.dart | 211 ++++++++++++------ lib/shared/widgets/model_avatar.dart | 56 +++++ lib/shared/widgets/user_avatar.dart | 125 +++++++++++ 9 files changed, 650 insertions(+), 113 deletions(-) create mode 100644 lib/core/utils/model_icon_utils.dart create mode 100644 lib/core/utils/user_avatar_utils.dart create mode 100644 lib/shared/widgets/model_avatar.dart create mode 100644 lib/shared/widgets/user_avatar.dart diff --git a/lib/core/models/model.dart b/lib/core/models/model.dart index 27d32f7..b0fcac7 100644 --- a/lib/core/models/model.dart +++ b/lib/core/models/model.dart @@ -69,6 +69,48 @@ sealed class Model with _$Model { ?.map((e) => e.toString()) .toList(); + final baseMetadata = Map.from( + (json['metadata'] as Map?) ?? const {}, + ); + + final metaSection = json['meta'] as Map?; + final infoSection = json['info'] as Map?; + + 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?)?['profile_image_url'] + as String?; + + final mergedMetadata = { + ...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?) ?? const {}; + mergedMetadata['meta'] = {...existing, ...metaSection}; + } + + if (infoSection != null) { + final existingInfo = + (mergedMetadata['info'] as Map?) ?? const {}; + mergedMetadata['info'] = {...existingInfo, ...infoSection}; + } + return Model( id: json['id'] as String, name: json['name'] as String, @@ -83,11 +125,7 @@ sealed class Model with _$Model { 'context_length': json['context_length'], 'supported_parameters': supportedParamsList ?? supportedParams, }, - metadata: { - 'canonical_slug': json['canonical_slug'], - 'created': json['created'], - 'connection_type': json['connection_type'], - }, + metadata: mergedMetadata, ); } } diff --git a/lib/core/utils/model_icon_utils.dart b/lib/core/utils/model_icon_utils.dart new file mode 100644 index 0000000..a49eee4 --- /dev/null +++ b/lib/core/utils/model_icon_utils.dart @@ -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? 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 {}; + final capabilities = model.capabilities ?? const {}; + final info = metadata['info'] as Map?; + final infoMeta = info?['meta'] as Map?; + final nestedMeta = metadata['meta'] as Map?; + + final candidates = [ + pick(metadata), + pick(nestedMeta), + pick(info), + pick(infoMeta), + pick(capabilities), + pick(capabilities['meta'] as Map?), + ]; + + 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); +} diff --git a/lib/core/utils/user_avatar_utils.dart b/lib/core/utils/user_avatar_utils.dart new file mode 100644 index 0000000..a4cb227 --- /dev/null +++ b/lib/core/utils/user_avatar_utils.dart @@ -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); +} diff --git a/lib/features/chat/views/chat_page.dart b/lib/features/chat/views/chat_page.dart index a535ae2..6863e4a 100644 --- a/lib/features/chat/views/chat_page.dart +++ b/lib/features/chat/views/chat_page.dart @@ -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 { return _buildEmptyState(Theme.of(context)); } + final apiService = ref.watch(apiServiceProvider); + return OptimizedList( key: const ValueKey('actual_messages'), scrollController: _scrollController, @@ -717,6 +721,7 @@ class _ChatPageState extends ConsumerState { // 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 { 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 { } } + final modelIconUrl = resolveModelIconUrlForModel( + apiService, + matchedModel, + ); + // Wrap message in selection container if in selection mode Widget messageWidget; @@ -770,6 +781,7 @@ class _ChatPageState extends ConsumerState { message: message, isStreaming: isStreaming, modelName: displayModelName, + modelIconUrl: modelIconUrl, onCopy: () => _copyMessage(message.content), onRegenerate: () => _regenerateMessage(message), ); @@ -917,7 +929,6 @@ class _ChatPageState extends ConsumerState { 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( diff --git a/lib/features/chat/widgets/assistant_message_widget.dart b/lib/features/chat/widgets/assistant_message_widget.dart index 3ca94a7..e78bd62 100644 --- a/lib/features/chat/widgets/assistant_message_widget.dart +++ b/lib/features/chat/widgets/assistant_message_widget.dart @@ -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 _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 } 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, diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 20b69d2..4c65039 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -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 { (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 { ); 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 { 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 { 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 { 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, ), ), ), diff --git a/lib/features/profile/views/profile_page.dart b/lib/features/profile/views/profile_page.dart index f8a9aa9..9bae9d2 100644 --- a/lib/features/profile/views/profile_page.dart +++ b/lib/features/profile/views/profile_page.dart @@ -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( diff --git a/lib/shared/widgets/model_avatar.dart b/lib/shared/widgets/model_avatar.dart new file mode 100644 index 0000000..7e62c37 --- /dev/null +++ b/lib/shared/widgets/model_avatar.dart @@ -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, + ), + ); + }, + ); + } +} diff --git a/lib/shared/widgets/user_avatar.dart b/lib/shared/widgets/user_avatar.dart new file mode 100644 index 0000000..b145a1f --- /dev/null +++ b/lib/shared/widgets/user_avatar.dart @@ -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, + ), + ); + } +}