feat: folders implementation

This commit is contained in:
cogwheel0
2025-08-17 00:05:30 +05:30
parent d57ddf67c5
commit 3623422475
11 changed files with 1884 additions and 1005 deletions

View File

@@ -1,41 +1,63 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'folder.freezed.dart'; part 'folder.freezed.dart';
part 'folder.g.dart';
// Timestamp converter for Unix timestamps
class TimestampConverter implements JsonConverter<DateTime, dynamic> {
const TimestampConverter();
@override
DateTime fromJson(dynamic json) {
if (json is String) {
return DateTime.parse(json);
} else if (json is int) {
return DateTime.fromMillisecondsSinceEpoch(json * 1000);
} else {
throw ArgumentError('Invalid date format: $json');
}
}
@override
dynamic toJson(DateTime object) {
return object.millisecondsSinceEpoch ~/ 1000;
}
}
@freezed @freezed
sealed class Folder with _$Folder { sealed class Folder with _$Folder {
const factory Folder({ const factory Folder({
required String id, required String id,
required String name, required String name,
@TimestampConverter() required DateTime createdAt,
@TimestampConverter() required DateTime updatedAt,
String? parentId, String? parentId,
String? userId,
DateTime? createdAt,
DateTime? updatedAt,
@Default(false) bool isExpanded,
@Default([]) List<String> conversationIds, @Default([]) List<String> conversationIds,
@Default([]) List<Folder> subfolders, Map<String, dynamic>? meta,
@Default({}) Map<String, dynamic> metadata, Map<String, dynamic>? data,
Map<String, dynamic>? items,
}) = _Folder; }) = _Folder;
factory Folder.fromJson(Map<String, dynamic> json) => _$FolderFromJson(json); factory Folder.fromJson(Map<String, dynamic> json) {
// Extract conversation IDs from items.chats if available
final items = json['items'] as Map<String, dynamic>?;
final chats = items?['chats'] as List?;
// Handle both string IDs and conversation objects
final conversationIds = chats?.map((chat) {
if (chat is String) {
return chat;
} else if (chat is Map<String, dynamic>) {
return chat['id'] as String? ?? '';
}
return '';
}).where((id) => id.isNotEmpty).toList().cast<String>() ?? <String>[];
// Handle Unix timestamp conversion
DateTime? parseTimestamp(dynamic timestamp) {
if (timestamp == null) return null;
if (timestamp is int) {
return DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
}
if (timestamp is String) {
return DateTime.parse(timestamp);
}
return null;
}
// Create the modified JSON with proper field mapping
return Folder(
id: json['id'] as String,
name: json['name'] as String,
parentId: json['parent_id'] as String?,
userId: json['user_id'] as String?,
createdAt: parseTimestamp(json['created_at']),
updatedAt: parseTimestamp(json['updated_at']),
isExpanded: json['is_expanded'] as bool? ?? false,
conversationIds: conversationIds,
meta: json['meta'] as Map<String, dynamic>?,
data: json['data'] as Map<String, dynamic>?,
items: json['items'] as Map<String, dynamic>?,
);
}
} }

View File

@@ -13,6 +13,7 @@ import '../models/server_config.dart';
import '../models/user.dart'; import '../models/user.dart';
import '../models/model.dart'; import '../models/model.dart';
import '../models/conversation.dart'; import '../models/conversation.dart';
import '../models/folder.dart';
import '../models/user_settings.dart'; import '../models/user_settings.dart';
import '../models/folder.dart'; import '../models/folder.dart';
import '../models/file_info.dart'; import '../models/file_info.dart';
@@ -278,7 +279,60 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
foundation.debugPrint( foundation.debugPrint(
'DEBUG: Successfully fetched ${conversations.length} conversations', 'DEBUG: Successfully fetched ${conversations.length} conversations',
); );
return conversations;
// Also fetch folder information and update conversations with folder IDs
try {
final foldersData = await api.getFolders();
foundation.debugPrint('DEBUG: Fetched ${foldersData.length} folders for conversation mapping');
// Parse folder data into Folder objects
final folders = foldersData.map((folderData) => Folder.fromJson(folderData)).toList();
// Create a map of conversation ID to folder ID
final conversationToFolder = <String, String>{};
for (final folder in folders) {
for (final conversationId in folder.conversationIds) {
conversationToFolder[conversationId] = folder.id;
}
}
// Update conversations with folder IDs and add missing folder conversations
final updatedConversations = <Conversation>[];
final existingConversationIds = conversations.map((c) => c.id).toSet();
for (final conversation in conversations) {
final folderId = conversationToFolder[conversation.id];
if (folderId != null) {
updatedConversations.add(conversation.copyWith(folderId: folderId));
foundation.debugPrint('DEBUG: Updated conversation ${conversation.id.substring(0, 8)} with folderId: $folderId');
} else {
updatedConversations.add(conversation);
}
}
// Add conversations that are in folders but not in the main list
for (final folder in folders) {
for (final conversationId in folder.conversationIds) {
if (!existingConversationIds.contains(conversationId)) {
// Create a minimal conversation object for folder-only conversations
// We'll need to fetch the full conversation details
try {
final fullConversation = await api.getConversation(conversationId);
updatedConversations.add(fullConversation.copyWith(folderId: folder.id));
foundation.debugPrint('DEBUG: Added folder conversation ${conversationId.substring(0, 8)} from folder ${folder.name}');
} catch (e) {
foundation.debugPrint('DEBUG: Failed to fetch folder conversation $conversationId: $e');
}
}
}
}
foundation.debugPrint('DEBUG: Final conversation count: ${updatedConversations.length}');
return updatedConversations;
} catch (e) {
foundation.debugPrint('DEBUG: Failed to fetch folder information: $e');
return conversations; // Return original conversations if folder fetch fails
}
} catch (e, stackTrace) { } catch (e, stackTrace) {
foundation.debugPrint('DEBUG: Error fetching conversations: $e'); foundation.debugPrint('DEBUG: Error fetching conversations: $e');
foundation.debugPrint('DEBUG: Stack trace: $stackTrace'); foundation.debugPrint('DEBUG: Stack trace: $stackTrace');
@@ -649,13 +703,20 @@ final conversationSuggestionsProvider = FutureProvider<List<String>>((
// Folders provider // Folders provider
final foldersProvider = FutureProvider<List<Folder>>((ref) async { final foldersProvider = FutureProvider<List<Folder>>((ref) async {
final api = ref.watch(apiServiceProvider); final api = ref.watch(apiServiceProvider);
if (api == null) return []; if (api == null) {
foundation.debugPrint('DEBUG: No API service available for folders');
return [];
}
try { try {
foundation.debugPrint('DEBUG: Fetching folders from API...');
final foldersData = await api.getFolders(); final foldersData = await api.getFolders();
return foldersData foundation.debugPrint('DEBUG: Raw folders data: $foldersData');
final folders = foldersData
.map((folderData) => Folder.fromJson(folderData)) .map((folderData) => Folder.fromJson(folderData))
.toList(); .toList();
foundation.debugPrint('DEBUG: Parsed ${folders.length} folders');
return folders;
} catch (e) { } catch (e) {
foundation.debugPrint('DEBUG: Error fetching folders: $e'); foundation.debugPrint('DEBUG: Error fetching folders: $e');
return []; return [];

View File

@@ -404,6 +404,17 @@ class ApiService {
// Process regular conversations (excluding pinned and archived ones) // Process regular conversations (excluding pinned and archived ones)
for (final chatData in regularChatList) { for (final chatData in regularChatList) {
try { try {
// Debug: Check if conversation has folder_id in raw data
if (chatData.containsKey('folder_id') && chatData['folder_id'] != null) {
debugPrint('🔍 DEBUG: Found conversation with folder_id in raw data: ${chatData['id']} -> ${chatData['folder_id']}');
}
// Debug: Check what fields are available in the chat data
if (regularChatList.indexOf(chatData) == 0) {
debugPrint('🔍 DEBUG: Sample chat data fields: ${chatData.keys.toList()}');
debugPrint('🔍 DEBUG: Sample chat data: ${chatData.toString().substring(0, 200)}...');
}
final conversation = _parseOpenWebUIChat(chatData); final conversation = _parseOpenWebUIChat(chatData);
// Only add if not already added as pinned or archived // Only add if not already added as pinned or archived
if (!pinnedIds.contains(conversation.id) && if (!pinnedIds.contains(conversation.id) &&
@@ -478,6 +489,11 @@ class ApiService {
final shareId = chatData['share_id'] as String?; final shareId = chatData['share_id'] as String?;
final folderId = chatData['folder_id'] as String?; final folderId = chatData['folder_id'] as String?;
// Debug logging for folder assignment
if (folderId != null) {
debugPrint('🔍 DEBUG: Conversation ${id.substring(0, 8)} has folderId: $folderId');
}
debugPrint( debugPrint(
'DEBUG: Parsed conversation $id: pinned=$pinned, archived=$archived', 'DEBUG: Parsed conversation $id: pinned=$pinned, archived=$archived',
); );
@@ -929,14 +945,25 @@ class ApiService {
// Folders // Folders
Future<List<Map<String, dynamic>>> getFolders() async { Future<List<Map<String, dynamic>>> getFolders() async {
debugPrint('DEBUG: Fetching folders'); try {
debugPrint('DEBUG: Fetching folders from /api/v1/folders/');
final response = await _dio.get('/api/v1/folders/'); final response = await _dio.get('/api/v1/folders/');
debugPrint('DEBUG: Folders response status: ${response.statusCode}');
debugPrint('DEBUG: Folders response data: ${response.data}');
final data = response.data; final data = response.data;
if (data is List) { if (data is List) {
debugPrint('DEBUG: Found ${data.length} folders');
return data.cast<Map<String, dynamic>>(); return data.cast<Map<String, dynamic>>();
} } else {
debugPrint('DEBUG: Response data is not a list: ${data.runtimeType}');
return []; return [];
} }
} catch (e) {
debugPrint('DEBUG: Error in getFolders: $e');
rethrow;
}
}
Future<Map<String, dynamic>> createFolder({ Future<Map<String, dynamic>> createFolder({
required String name, required String name,

View File

@@ -6,6 +6,7 @@ import '../../features/auth/views/connect_signin_page.dart';
import '../../features/settings/views/searchable_settings_page.dart'; import '../../features/settings/views/searchable_settings_page.dart';
import '../../features/profile/views/profile_page.dart'; import '../../features/profile/views/profile_page.dart';
import '../../features/files/views/files_page.dart'; import '../../features/files/views/files_page.dart';
import '../../features/chat/views/conversation_search_page.dart'; import '../../features/chat/views/conversation_search_page.dart';
import '../../shared/widgets/themed_dialogs.dart'; import '../../shared/widgets/themed_dialogs.dart';
@@ -221,6 +222,8 @@ class NavigationService {
page = const FilesPage(); page = const FilesPage();
break; break;
case Routes.chatsList: case Routes.chatsList:
page = const ChatsListPage(); page = const ChatsListPage();
break; break;
@@ -246,5 +249,6 @@ class Routes {
static const String serverConnection = '/server-connection'; static const String serverConnection = '/server-connection';
static const String search = '/search'; static const String search = '/search';
static const String files = '/files'; static const String files = '/files';
static const String chatsList = '/chats-list'; static const String chatsList = '/chats-list';
} }

View File

@@ -105,7 +105,9 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
} }
// Default error UI // Default error UI
return Scaffold( return Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
backgroundColor: context.conduitTheme.surfaceBackground, backgroundColor: context.conduitTheme.surfaceBackground,
body: SafeArea( body: SafeArea(
child: Padding( child: Padding(
@@ -145,6 +147,7 @@ class _ErrorBoundaryState extends ConsumerState<ErrorBoundary> {
), ),
), ),
), ),
),
); );
} }

View File

@@ -546,6 +546,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
_navigateToFiles(); _navigateToFiles();
}, },
), ),
ListTile( ListTile(
leading: Icon( leading: Icon(
Platform.isIOS ? CupertinoIcons.person : Icons.person_outline, Platform.isIOS ? CupertinoIcons.person : Icons.person_outline,
@@ -582,6 +583,8 @@ class _ChatPageState extends ConsumerState<ChatPage> {
).push(MaterialPageRoute(builder: (context) => const FilesPage())); ).push(MaterialPageRoute(builder: (context) => const FilesPage()));
} }
void _navigateToProfile() { void _navigateToProfile() {
Navigator.of( Navigator.of(
context, context,
@@ -1229,6 +1232,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
}, },
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Flexible( Flexible(
child: Text( child: Text(
@@ -1239,6 +1243,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
), ),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
), ),
), ),
const SizedBox(width: Spacing.xs), const SizedBox(width: Spacing.xs),
@@ -1618,25 +1623,7 @@ class _ModelSelectorSheetState extends ConsumerState<_ModelSelectorSheet> {
), ),
), ),
// Header
Padding(
padding: const EdgeInsets.only(bottom: Spacing.sm),
child: Row(
children: [
Expanded(
child: Text(
'Choose Model',
style: TextStyle(
color: context.conduitTheme.textPrimary,
fontSize: AppTypography.headlineMedium,
fontWeight: FontWeight.w600,
),
),
),
// Removed capabilities legend to reduce icon noise
],
),
),
// Search field // Search field
Padding( Padding(

View File

@@ -6,6 +6,7 @@ import '../../../core/models/model.dart';
import '../../../core/providers/app_providers.dart'; import '../../../core/providers/app_providers.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../../shared/theme/app_theme.dart'; import '../../../shared/theme/app_theme.dart';
import '../../../shared/widgets/conduit_components.dart';
class ModelSelectorPage extends ConsumerStatefulWidget { class ModelSelectorPage extends ConsumerStatefulWidget {
const ModelSelectorPage({super.key}); const ModelSelectorPage({super.key});
@@ -53,17 +54,27 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final modelsAsync = ref.watch(modelsProvider); final modelsAsync = ref.watch(modelsProvider);
final selectedModel = ref.watch(selectedModelProvider); final selectedModel = ref.watch(selectedModelProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Select Model'), backgroundColor: context.conduitTheme.surfaceBackground,
leading: IconButton( elevation: Elevation.none,
icon: Icon(Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back), scrolledUnderElevation: Elevation.none,
leading: ConduitIconButton(
icon: Platform.isIOS
? CupertinoIcons.back
: Icons.arrow_back_rounded,
onPressed: () => Navigator.pop(context), onPressed: () => Navigator.pop(context),
), ),
title: Text(
'Select Model',
style: AppTypography.headlineMediumStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
),
), ),
body: Column( body: Column(
children: [ children: [
@@ -71,10 +82,10 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
Container( Container(
padding: const EdgeInsets.all(Spacing.md), padding: const EdgeInsets.all(Spacing.md),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.scaffoldBackgroundColor, color: context.conduitTheme.surfaceBackground,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.1), color: context.conduitTheme.dividerColor.withValues(alpha: 0.1),
width: BorderWidth.regular, width: BorderWidth.regular,
), ),
), ),
@@ -96,27 +107,22 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
Platform.isIOS Platform.isIOS
? CupertinoIcons.cube_box ? CupertinoIcons.cube_box
: Icons.view_in_ar, : Icons.view_in_ar,
size: 64, size: IconSize.xxl,
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.iconSecondary,
alpha: 0.3,
), ),
), const SizedBox(height: Spacing.lg),
const SizedBox(height: Spacing.md),
Text( Text(
'No models available', 'No models available',
style: theme.textTheme.titleMedium?.copyWith( style: AppTypography.headlineSmallStyle.copyWith(
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.textPrimary,
alpha: 0.6, fontWeight: FontWeight.w600,
),
), ),
), ),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
Text( Text(
'Please check your Open-WebUI configuration', 'Please check your Open-WebUI configuration',
style: theme.textTheme.bodyMedium?.copyWith( style: AppTypography.bodyMediumStyle.copyWith(
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.textSecondary,
alpha: 0.5,
),
), ),
), ),
], ],
@@ -132,28 +138,23 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
Icon( Icon(
Platform.isIOS Platform.isIOS
? CupertinoIcons.search ? CupertinoIcons.search
: Icons.search_off, : Icons.search_rounded,
size: 64, size: IconSize.xxl,
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.iconSecondary,
alpha: 0.3,
), ),
), const SizedBox(height: Spacing.lg),
const SizedBox(height: Spacing.md),
Text( Text(
'No models found', 'No models found',
style: theme.textTheme.titleMedium?.copyWith( style: AppTypography.headlineSmallStyle.copyWith(
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.textPrimary,
alpha: 0.6, fontWeight: FontWeight.w600,
),
), ),
), ),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
Text( Text(
'Try searching with different keywords', 'Try searching with different keywords',
style: theme.textTheme.bodyMedium?.copyWith( style: AppTypography.bodyMediumStyle.copyWith(
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.textSecondary,
alpha: 0.5,
),
), ),
), ),
], ],
@@ -183,9 +184,10 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
), ),
child: Text( child: Text(
group.title!, group.title!,
style: theme.textTheme.titleSmall?.copyWith( style: AppTypography.labelStyle.copyWith(
color: theme.colorScheme.primary, color: context.conduitTheme.textSecondary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
letterSpacing: 0.5,
), ),
), ),
), ),
@@ -206,7 +208,13 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
}, },
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
context.conduitTheme.buttonPrimary,
),
),
),
error: (error, _) => Center( error: (error, _) => Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -214,32 +222,48 @@ class _ModelSelectorPageState extends ConsumerState<ModelSelectorPage> {
Icon( Icon(
Platform.isIOS Platform.isIOS
? CupertinoIcons.exclamationmark_triangle ? CupertinoIcons.exclamationmark_triangle
: Icons.error_outline, : Icons.error_rounded,
size: 48, size: IconSize.xxl,
color: theme.colorScheme.error, color: context.conduitTheme.error,
), ),
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.lg),
Text( Text(
'Failed to load models', 'Failed to load models',
style: theme.textTheme.titleMedium, style: AppTypography.headlineSmallStyle.copyWith(
color: context.conduitTheme.textPrimary,
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: Spacing.sm), const SizedBox(height: Spacing.sm),
Text( Text(
error.toString(), 'Please try again later',
style: theme.textTheme.bodyMedium?.copyWith( style: AppTypography.bodyMediumStyle.copyWith(
color: theme.colorScheme.onSurface.withValues( color: context.conduitTheme.textSecondary,
alpha: 0.6,
),
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: Spacing.lg), const SizedBox(height: Spacing.xl),
ElevatedButton.icon( ElevatedButton(
onPressed: () => ref.refresh(modelsProvider), onPressed: () => ref.refresh(modelsProvider),
icon: Icon( style: ElevatedButton.styleFrom(
Platform.isIOS ? CupertinoIcons.refresh : Icons.refresh, backgroundColor: context.conduitTheme.buttonPrimary,
foregroundColor: context.conduitTheme.buttonPrimaryText,
padding: const EdgeInsets.symmetric(
horizontal: Spacing.buttonPadding,
vertical: Spacing.md,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.button),
),
elevation: Elevation.none,
),
child: Text(
'Retry',
style: AppTypography.labelStyle.copyWith(
color: context.conduitTheme.buttonPrimaryText,
fontWeight: FontWeight.w600,
),
), ),
label: const Text('Retry'),
), ),
], ],
), ),
@@ -339,20 +363,18 @@ class ModelTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card( return Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: isSelected ? 2 : 0, elevation: isSelected ? 2 : 0,
color: isSelected color: isSelected
? theme.colorScheme.primary.withValues(alpha: 0.1) ? context.conduitTheme.buttonPrimary.withValues(alpha: 0.1)
: null, : context.conduitTheme.cardBackground,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppBorderRadius.md), borderRadius: BorderRadius.circular(AppBorderRadius.md),
side: BorderSide( side: BorderSide(
color: isSelected color: isSelected
? theme.colorScheme.primary ? context.conduitTheme.buttonPrimary
: theme.dividerColor.withValues(alpha: 0.3), : context.conduitTheme.dividerColor.withValues(alpha: 0.3),
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
), ),
@@ -369,9 +391,11 @@ class ModelTile extends StatelessWidget {
Expanded( Expanded(
child: Text( child: Text(
model.name, model.name,
style: theme.textTheme.titleMedium?.copyWith( style: AppTypography.bodyLargeStyle.copyWith(
fontWeight: isSelected ? FontWeight.w600 : null, fontWeight: FontWeight.w600,
color: isSelected ? theme.colorScheme.primary : null, color: isSelected
? context.conduitTheme.buttonPrimary
: context.conduitTheme.textPrimary,
), ),
), ),
), ),
@@ -380,7 +404,7 @@ class ModelTile extends StatelessWidget {
Platform.isIOS Platform.isIOS
? CupertinoIcons.checkmark_circle_fill ? CupertinoIcons.checkmark_circle_fill
: Icons.check_circle, : Icons.check_circle,
color: theme.colorScheme.primary, color: context.conduitTheme.buttonPrimary,
), ),
], ],
), ),
@@ -388,8 +412,8 @@ class ModelTile extends StatelessWidget {
const SizedBox(height: Spacing.xs), const SizedBox(height: Spacing.xs),
Text( Text(
model.description!, model.description!,
style: theme.textTheme.bodySmall?.copyWith( style: AppTypography.bodySmallStyle.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), color: context.conduitTheme.textSecondary,
), ),
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,

File diff suppressed because it is too large Load Diff

View File

@@ -303,7 +303,10 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
left: Spacing.xxxl, left: Spacing.xxxl,
right: Spacing.xs, right: Spacing.xs,
), ),
child: Row( child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Flexible( Flexible(
@@ -352,19 +355,21 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
context.conduitTheme.chatBubbleUserText, context.conduitTheme.chatBubbleUserText,
), ),
], ],
],
),
),
),
),
],
),
// Action buttons for user messages // Action buttons below the message bubble
if (_showActions) ...[ if (_showActions) ...[
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.sm),
_buildUserActionButtons(), _buildUserActionButtons(),
], ],
], ],
), ),
),
),
),
],
),
) )
.animate() .animate()
.fadeIn(duration: AnimationDuration.messageAppear) .fadeIn(duration: AnimationDuration.messageAppear)
@@ -461,18 +466,18 @@ class _ModernMessageBubbleState extends ConsumerState<ModernMessageBubble>
] else ] else
// Fallback: show empty state for non-streaming empty messages // Fallback: show empty state for non-streaming empty messages
const SizedBox.shrink(), const SizedBox.shrink(),
],
),
),
),
// Action buttons // Action buttons below the message content
if (_showActions) ...[ if (_showActions) ...[
const SizedBox(height: Spacing.md), const SizedBox(height: Spacing.sm),
_buildActionButtons(), _buildActionButtons(),
], ],
], ],
), ),
),
),
],
),
) )
.animate() .animate()
.fadeIn(duration: AnimationDuration.messageAppear) .fadeIn(duration: AnimationDuration.messageAppear)

File diff suppressed because it is too large Load Diff

View File

@@ -483,8 +483,10 @@ class ConduitEmptyState extends StatelessWidget {
return Center( return Center(
child: Padding( child: Padding(
padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg), padding: EdgeInsets.all(isCompact ? Spacing.md : Spacing.lg),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md, width: isCompact ? IconSize.xxl : IconSize.xxl + Spacing.md,
@@ -515,6 +517,8 @@ class ConduitEmptyState extends StatelessWidget {
color: context.conduitTheme.textSecondary, color: context.conduitTheme.textSecondary,
), ),
textAlign: TextAlign.center, textAlign: TextAlign.center,
maxLines: isCompact ? 2 : null,
overflow: isCompact ? TextOverflow.ellipsis : null,
), ),
if (action != null) ...[ if (action != null) ...[
SizedBox(height: isCompact ? Spacing.md : Spacing.lg), SizedBox(height: isCompact ? Spacing.md : Spacing.lg),
@@ -523,6 +527,7 @@ class ConduitEmptyState extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }