feat: model and user avatars
This commit is contained in:
@@ -69,6 +69,48 @@ sealed class Model with _$Model {
|
||||
?.map((e) => e.toString())
|
||||
.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(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
100
lib/core/utils/model_icon_utils.dart
Normal file
100
lib/core/utils/model_icon_utils.dart
Normal 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);
|
||||
}
|
||||
95
lib/core/utils/user_avatar_utils.dart
Normal file
95
lib/core/utils/user_avatar_utils.dart
Normal 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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,21 +179,53 @@ class ProfilePage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProfileHeader(dynamic user) {
|
||||
return Builder(
|
||||
builder: (context) => ConduitCard(
|
||||
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: [
|
||||
// Enhanced avatar with better sizing and shadows
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.avatar),
|
||||
boxShadow: ConduitShadows.card,
|
||||
),
|
||||
child: ConduitAvatar(
|
||||
child: UserAvatar(
|
||||
size: IconSize.avatar,
|
||||
text: user?.name?.substring(0, 1) ?? 'U',
|
||||
imageUrl: avatarUrl,
|
||||
fallbackText: initial,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Spacing.md),
|
||||
@@ -194,26 +234,31 @@ class ProfilePage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
user?.name ?? 'User',
|
||||
style: context.conduitTheme.headingMedium?.copyWith(
|
||||
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(
|
||||
user?.email ?? 'No email',
|
||||
style: context.conduitTheme.bodyMedium?.copyWith(
|
||||
email,
|
||||
style:
|
||||
context.conduitTheme.bodyMedium?.copyWith(
|
||||
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) {
|
||||
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(
|
||||
|
||||
56
lib/shared/widgets/model_avatar.dart
Normal file
56
lib/shared/widgets/model_avatar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
125
lib/shared/widgets/user_avatar.dart
Normal file
125
lib/shared/widgets/user_avatar.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user