feat: comprehensive reviewer mode
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -11,7 +11,7 @@
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
.github/copilot-instructions.md
|
||||
.AGENTS.md
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
|
||||
@@ -13,9 +13,9 @@ import '../models/server_config.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/model.dart';
|
||||
import '../models/conversation.dart';
|
||||
import '../models/chat_message.dart';
|
||||
import '../models/folder.dart';
|
||||
import '../models/user_settings.dart';
|
||||
import '../models/folder.dart';
|
||||
import '../models/file_info.dart';
|
||||
import '../models/knowledge_base.dart';
|
||||
import '../services/optimized_storage_service.dart';
|
||||
@@ -252,8 +252,18 @@ final modelsProvider = FutureProvider<List<Model>>((ref) async {
|
||||
|
||||
final selectedModelProvider = StateProvider<Model?>((ref) => null);
|
||||
|
||||
// Conversation providers - Now using correct OpenWebUI API
|
||||
// Cache timestamp for conversations to prevent rapid re-fetches
|
||||
final _conversationsCacheTimestamp = StateProvider<DateTime?>((ref) => null);
|
||||
|
||||
// Conversation providers - Now using correct OpenWebUI API with caching
|
||||
final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
|
||||
// Check if we have a recent cache (within 5 seconds)
|
||||
final lastFetch = ref.read(_conversationsCacheTimestamp);
|
||||
if (lastFetch != null && DateTime.now().difference(lastFetch).inSeconds < 5) {
|
||||
foundation.debugPrint('DEBUG: Using cached conversations (fetched ${DateTime.now().difference(lastFetch).inSeconds}s ago)');
|
||||
// Note: Can't read our own provider here, would cause a cycle
|
||||
// The caching is handled by Riverpod's built-in mechanism
|
||||
}
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
// Provide a simple local demo conversation list
|
||||
@@ -263,7 +273,16 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
|
||||
title: 'Welcome to Conduit (Demo)',
|
||||
createdAt: DateTime.now().subtract(const Duration(minutes: 15)),
|
||||
updatedAt: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
messages: [],
|
||||
messages: [
|
||||
ChatMessage(
|
||||
id: 'demo-msg-1',
|
||||
role: 'assistant',
|
||||
content: '**Welcome to Conduit Demo Mode**\n\nThis is a demo for app review - responses are pre-written, not from real AI.\n\nTry these features:\n• Send messages\n• Attach images\n• Use voice input\n• Switch models (tap header)\n• Create new chats (menu)\n\nAll features work offline. No server needed.',
|
||||
timestamp: DateTime.now().subtract(const Duration(minutes: 10)),
|
||||
model: 'Gemma 2 Mini (Demo)',
|
||||
isStreaming: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -291,54 +310,112 @@ final conversationsProvider = FutureProvider<List<Conversation>>((ref) async {
|
||||
// Create a map of conversation ID to folder ID
|
||||
final conversationToFolder = <String, String>{};
|
||||
for (final folder in folders) {
|
||||
foundation.debugPrint('DEBUG: Folder "${folder.name}" (${folder.id}) has ${folder.conversationIds.length} conversations');
|
||||
for (final conversationId in folder.conversationIds) {
|
||||
conversationToFolder[conversationId] = folder.id;
|
||||
foundation.debugPrint('DEBUG: Mapping conversation $conversationId to folder ${folder.id}');
|
||||
}
|
||||
}
|
||||
|
||||
// Update conversations with folder IDs and add missing folder conversations
|
||||
final updatedConversations = <Conversation>[];
|
||||
final existingConversationIds = conversations.map((c) => c.id).toSet();
|
||||
// Update conversations with folder IDs, preferring explicit folder_id from chat if present
|
||||
// Use a map to ensure uniqueness by ID throughout the merge process
|
||||
final conversationMap = <String, Conversation>{};
|
||||
|
||||
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');
|
||||
// Prefer server-provided folderId on the chat itself
|
||||
final explicitFolderId = conversation.folderId;
|
||||
final mappedFolderId = conversationToFolder[conversation.id];
|
||||
final folderIdToUse = explicitFolderId ?? mappedFolderId;
|
||||
if (folderIdToUse != null) {
|
||||
conversationMap[conversation.id] = conversation.copyWith(folderId: folderIdToUse);
|
||||
foundation.debugPrint('DEBUG: Updated conversation ${conversation.id.substring(0, 8)} with folderId: $folderIdToUse (explicit: ${explicitFolderId != null})');
|
||||
} else {
|
||||
updatedConversations.add(conversation);
|
||||
conversationMap[conversation.id] = conversation;
|
||||
}
|
||||
}
|
||||
|
||||
// Add conversations that are in folders but not in the main list
|
||||
// Merge conversations that are in folders but missing from the main list
|
||||
// Build a set of existing IDs from the fetched list
|
||||
final existingIds = conversationMap.keys.toSet();
|
||||
|
||||
// Diagnostics: count how many folder-mapped IDs are missing from the main list
|
||||
final missingInBase = conversationToFolder.keys.where((id) => !existingIds.contains(id)).toList();
|
||||
if (missingInBase.isNotEmpty) {
|
||||
foundation.debugPrint('DEBUG: ${missingInBase.length} conversations referenced by folders are missing from base list');
|
||||
final preview = missingInBase.take(10).toList();
|
||||
foundation.debugPrint('DEBUG: Missing IDs sample: $preview${missingInBase.length > 10 ? ' ...' : ''}');
|
||||
} else {
|
||||
foundation.debugPrint('DEBUG: All folder-referenced conversations are present in base list');
|
||||
}
|
||||
|
||||
// Attempt to fetch missing conversations per-folder to construct accurate entries
|
||||
// If per-folder fetch fails, fall back to creating minimal placeholder entries
|
||||
final apiSvc = ref.read(apiServiceProvider);
|
||||
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
|
||||
// Collect IDs in this folder that are missing
|
||||
final missingIds = folder.conversationIds.where((id) => !existingIds.contains(id)).toList();
|
||||
if (missingIds.isEmpty) continue;
|
||||
|
||||
List<Conversation> folderConvs = const [];
|
||||
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');
|
||||
if (apiSvc != null) {
|
||||
folderConvs = await apiSvc.getConversationsInFolder(folder.id);
|
||||
}
|
||||
} catch (e) {
|
||||
foundation.debugPrint('DEBUG: getConversationsInFolder failed for ${folder.id}: $e');
|
||||
}
|
||||
|
||||
// Index fetched folder conversations for quick lookup
|
||||
final fetchedMap = {for (final c in folderConvs) c.id: c};
|
||||
|
||||
for (final convId in missingIds) {
|
||||
final fetched = fetchedMap[convId];
|
||||
if (fetched != null) {
|
||||
final toAdd = fetched.folderId == null
|
||||
? fetched.copyWith(folderId: folder.id)
|
||||
: fetched;
|
||||
// Use map to prevent duplicates - this will overwrite if ID already exists
|
||||
conversationMap[toAdd.id] = toAdd;
|
||||
existingIds.add(toAdd.id);
|
||||
foundation.debugPrint('DEBUG: Added missing conversation from folder fetch: ${toAdd.id.substring(0, 8)} -> folder ${folder.id}');
|
||||
} else {
|
||||
// Create a minimal placeholder if not returned by folder API
|
||||
final placeholder = Conversation(
|
||||
id: convId,
|
||||
title: 'Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: const [],
|
||||
folderId: folder.id,
|
||||
);
|
||||
// Use map to prevent duplicates
|
||||
conversationMap[convId] = placeholder;
|
||||
existingIds.add(convId);
|
||||
foundation.debugPrint('DEBUG: Added placeholder conversation for missing ID: ${convId.substring(0, 8)} -> folder ${folder.id}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foundation.debugPrint('DEBUG: Final conversation count: ${updatedConversations.length}');
|
||||
// Convert map back to list - this ensures no duplicates by ID
|
||||
final sortedConversations = conversationMap.values.toList();
|
||||
|
||||
// Sort conversations by updatedAt in descending order (most recent first)
|
||||
updatedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
sortedConversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
foundation.debugPrint('DEBUG: Sorted conversations by updatedAt (most recent first)');
|
||||
|
||||
return updatedConversations;
|
||||
// Update cache timestamp
|
||||
ref.read(_conversationsCacheTimestamp.notifier).state = DateTime.now();
|
||||
|
||||
return sortedConversations;
|
||||
} catch (e) {
|
||||
foundation.debugPrint('DEBUG: Failed to fetch folder information: $e');
|
||||
// Sort conversations even when folder fetch fails
|
||||
conversations.sort((a, b) => b.updatedAt.compareTo(a.updatedAt));
|
||||
foundation.debugPrint('DEBUG: Sorted conversations by updatedAt (fallback case)');
|
||||
|
||||
// Update cache timestamp
|
||||
ref.read(_conversationsCacheTimestamp.notifier).state = DateTime.now();
|
||||
|
||||
return conversations; // Return original conversations if folder fetch fails
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
@@ -385,6 +462,13 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
if (api == null) return null;
|
||||
|
||||
try {
|
||||
// Check if a model is already selected
|
||||
final currentSelected = ref.read(selectedModelProvider);
|
||||
if (currentSelected != null) {
|
||||
foundation.debugPrint('DEBUG: Model already selected: ${currentSelected.name}');
|
||||
return currentSelected;
|
||||
}
|
||||
|
||||
// Get all available models first
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isEmpty) {
|
||||
@@ -392,11 +476,11 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if a model is already selected
|
||||
final currentSelected = ref.read(selectedModelProvider);
|
||||
if (currentSelected != null) {
|
||||
foundation.debugPrint('DEBUG: Model already selected: ${currentSelected.name}');
|
||||
return currentSelected;
|
||||
// Double-check if a model was selected while we were loading
|
||||
final checkSelected = ref.read(selectedModelProvider);
|
||||
if (checkSelected != null) {
|
||||
foundation.debugPrint('DEBUG: Model was selected during loading: ${checkSelected.name}');
|
||||
return checkSelected;
|
||||
}
|
||||
|
||||
Model? selectedModel;
|
||||
@@ -440,9 +524,15 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
);
|
||||
}
|
||||
|
||||
// Set the selected model
|
||||
ref.read(selectedModelProvider.notifier).state = selectedModel;
|
||||
foundation.debugPrint('DEBUG: Set default model: ${selectedModel.name}');
|
||||
// Defer the state update to avoid modifying providers during initialization
|
||||
final modelToSet = selectedModel;
|
||||
Future.microtask(() {
|
||||
// Final check before setting
|
||||
if (ref.read(selectedModelProvider) == null) {
|
||||
ref.read(selectedModelProvider.notifier).state = modelToSet;
|
||||
foundation.debugPrint('DEBUG: Set default model: ${modelToSet.name}');
|
||||
}
|
||||
});
|
||||
|
||||
return selectedModel;
|
||||
} catch (e) {
|
||||
@@ -453,10 +543,15 @@ final defaultModelProvider = FutureProvider<Model?>((ref) async {
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
if (models.isNotEmpty) {
|
||||
final fallbackModel = models.first;
|
||||
// Defer the state update
|
||||
Future.microtask(() {
|
||||
if (ref.read(selectedModelProvider) == null) {
|
||||
ref.read(selectedModelProvider.notifier).state = fallbackModel;
|
||||
foundation.debugPrint(
|
||||
'DEBUG: Fallback to first available model: ${fallbackModel.name}',
|
||||
);
|
||||
}
|
||||
});
|
||||
return fallbackModel;
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
|
||||
@@ -133,7 +133,8 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
if (mounted && next.isAuthenticated && previous?.isAuthenticated != true) {
|
||||
debugPrint('DEBUG: Authentication successful, initializing background resources');
|
||||
|
||||
|
||||
// Model selection and onboarding will be handled by the chat page
|
||||
// to avoid widget disposal issues
|
||||
|
||||
debugPrint('DEBUG: Navigating to chat page');
|
||||
// Navigate directly to chat page on successful authentication
|
||||
@@ -620,4 +621,6 @@ class _AuthenticationPageState extends ConsumerState<AuthenticationPage> {
|
||||
curve: Curves.easeOutCubic,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/auth/auth_state_manager.dart';
|
||||
import '../../../core/utils/stream_chunker.dart';
|
||||
import '../../../core/services/persistent_streaming_service.dart';
|
||||
import '../services/reviewer_mode_service.dart';
|
||||
|
||||
// Chat messages for current conversation
|
||||
final chatMessagesProvider =
|
||||
@@ -399,9 +400,13 @@ Future<void> regenerateMessage(
|
||||
);
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
|
||||
|
||||
// Use canned response for regeneration
|
||||
final responseText = ReviewerModeService.generateResponse(
|
||||
userMessage: userMessageContent,
|
||||
);
|
||||
|
||||
// Simulate streaming response
|
||||
final demoText = 'This is a regenerated demo response.\n\nOriginal message: "$userMessageContent"';
|
||||
final words = demoText.split(' ');
|
||||
final words = responseText.split(' ');
|
||||
for (final word in words) {
|
||||
await Future.delayed(const Duration(milliseconds: 40));
|
||||
ref.read(chatMessagesProvider.notifier).appendToLastMessage('$word ');
|
||||
@@ -444,7 +449,7 @@ Future<void> regenerateMessage(
|
||||
}
|
||||
|
||||
// Stream response using SSE
|
||||
final response = await api!.sendMessage(
|
||||
final response = api!.sendMessage(
|
||||
messages: conversationMessages,
|
||||
model: selectedModel.id,
|
||||
conversationId: activeConversation.id,
|
||||
@@ -582,7 +587,10 @@ Future<void> _sendMessageInternal(
|
||||
);
|
||||
|
||||
// Invalidate conversations provider to refresh the list
|
||||
// Adding a small delay to prevent rapid invalidations that could cause duplicates
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
ref.invalidate(conversationsProvider);
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to create conversation on server, using local: $e',
|
||||
@@ -615,10 +623,27 @@ Future<void> _sendMessageInternal(
|
||||
);
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(assistantMessage);
|
||||
|
||||
// Check if there are attachments
|
||||
String? filename;
|
||||
if (attachments != null && attachments.isNotEmpty) {
|
||||
// Get the first attachment filename for the response
|
||||
// In reviewer mode, we just simulate having a file
|
||||
filename = "demo_file.txt";
|
||||
}
|
||||
|
||||
// Check if this is voice input
|
||||
// In reviewer mode, we don't have actual voice input state
|
||||
final isVoiceInput = false;
|
||||
|
||||
// Generate appropriate canned response
|
||||
final responseText = ReviewerModeService.generateResponse(
|
||||
userMessage: message,
|
||||
filename: filename,
|
||||
isVoiceInput: isVoiceInput,
|
||||
);
|
||||
|
||||
// Simulate token-by-token streaming
|
||||
final demoText =
|
||||
'This is a demo response from Conduit.\n\nYou typed: "$message"';
|
||||
final words = demoText.split(' ');
|
||||
final words = responseText.split(' ');
|
||||
for (final word in words) {
|
||||
await Future.delayed(const Duration(milliseconds: 40));
|
||||
ref.read(chatMessagesProvider.notifier).appendToLastMessage('$word ');
|
||||
@@ -1598,8 +1623,11 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
debugPrint(
|
||||
'DEBUG: Invalidating conversations provider after successful save',
|
||||
);
|
||||
// Adding a small delay to prevent rapid invalidations that could cause duplicates
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
ref.invalidate(conversationsProvider);
|
||||
debugPrint('DEBUG: Conversations provider invalidated');
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Error saving conversation to server: $e');
|
||||
// Fallback to local storage
|
||||
|
||||
@@ -383,8 +383,132 @@ class FileUploadState {
|
||||
|
||||
enum FileUploadStatus { pending, uploading, completed, failed }
|
||||
|
||||
// Mock file attachment service for reviewer mode
|
||||
class MockFileAttachmentService {
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
|
||||
// Reuse the same methods from parent class
|
||||
Future<List<File>> pickFiles({
|
||||
bool allowMultiple = true,
|
||||
List<String>? allowedExtensions,
|
||||
}) async {
|
||||
try {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
allowMultiple: allowMultiple,
|
||||
type: allowedExtensions != null ? FileType.custom : FileType.any,
|
||||
allowedExtensions: allowedExtensions,
|
||||
);
|
||||
|
||||
if (result == null || result.files.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.files
|
||||
.where((file) => file.path != null)
|
||||
.map((file) => File(file.path!))
|
||||
.toList();
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick files: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
);
|
||||
return image != null ? File(image.path) : null;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to pick image: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<File?> takePhoto() async {
|
||||
try {
|
||||
final XFile? photo = await _imagePicker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
return photo != null ? File(photo.path) : null;
|
||||
} catch (e) {
|
||||
throw Exception('Failed to take photo: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Mock upload file with progress tracking
|
||||
Stream<FileUploadState> uploadFile(File file) async* {
|
||||
foundation.debugPrint('DEBUG: Mock file upload for: ${file.path}');
|
||||
|
||||
final fileName = path.basename(file.path);
|
||||
final fileSize = await file.length();
|
||||
|
||||
// Yield initial state
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 0.0,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
|
||||
// Simulate upload progress
|
||||
for (int i = 1; i <= 10; i++) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: i / 10,
|
||||
status: FileUploadStatus.uploading,
|
||||
);
|
||||
}
|
||||
|
||||
// Yield completed state with mock file ID
|
||||
yield FileUploadState(
|
||||
file: file,
|
||||
fileName: fileName,
|
||||
fileSize: fileSize,
|
||||
progress: 1.0,
|
||||
status: FileUploadStatus.completed,
|
||||
fileId: 'mock_file_${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
foundation.debugPrint('DEBUG: Mock file upload completed');
|
||||
}
|
||||
|
||||
Future<List<String>> uploadFiles(
|
||||
List<File> files, {
|
||||
Function(int, int)? onProgress,
|
||||
required String conversationId,
|
||||
}) async {
|
||||
// Simulate upload progress for reviewer mode
|
||||
final uploadIds = <String>[];
|
||||
|
||||
for (int i = 0; i < files.length; i++) {
|
||||
if (onProgress != null) {
|
||||
// Simulate progress
|
||||
for (int j = 0; j <= 100; j += 10) {
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
onProgress(i, j);
|
||||
}
|
||||
}
|
||||
// Generate mock upload ID
|
||||
uploadIds.add('mock_upload_${DateTime.now().millisecondsSinceEpoch}_$i');
|
||||
}
|
||||
|
||||
return uploadIds;
|
||||
}
|
||||
}
|
||||
|
||||
// Providers
|
||||
final fileAttachmentServiceProvider = Provider<FileAttachmentService?>((ref) {
|
||||
final fileAttachmentServiceProvider = Provider<dynamic>((ref) {
|
||||
final isReviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
if (isReviewerMode) {
|
||||
return MockFileAttachmentService();
|
||||
}
|
||||
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
if (apiService == null) return null;
|
||||
return FileAttachmentService(apiService);
|
||||
|
||||
95
lib/features/chat/services/reviewer_mode_service.dart
Normal file
95
lib/features/chat/services/reviewer_mode_service.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'dart:math';
|
||||
|
||||
class ReviewerModeService {
|
||||
static final Random _random = Random();
|
||||
|
||||
// Categories of canned responses
|
||||
static const Map<String, List<String>> _cannedResponses = {
|
||||
'greeting': [
|
||||
'Hello! I\'m here to help you explore Conduit\'s features. What would you like to know?',
|
||||
'Hi there! Welcome to Conduit. How can I assist you today?',
|
||||
'Greetings! I\'m ready to help you test out Conduit\'s chat capabilities.',
|
||||
],
|
||||
'code': [
|
||||
'Here\'s a simple example of what I can help with:\n\n```python\ndef greet(name):\n return f"Hello, {name}!"\n\nprint(greet("Conduit User"))\n```\n\nI can assist with various programming languages and tasks.',
|
||||
'I can help you write and review code. For example:\n\n```javascript\nconst calculateSum = (numbers) => {\n return numbers.reduce((acc, num) => acc + num, 0);\n};\n\nconsole.log(calculateSum([1, 2, 3, 4, 5])); // Output: 15\n```',
|
||||
'Let me show you a code snippet:\n\n```typescript\ninterface User {\n id: string;\n name: string;\n email: string;\n}\n\nclass UserService {\n async getUser(id: string): Promise<User> {\n // Implementation here\n return { id, name: "Demo User", email: "demo@conduit.app" };\n }\n}\n```',
|
||||
],
|
||||
'features': [
|
||||
'Conduit offers several key features:\n\n• **Real-time streaming** - See responses as they\'re generated\n• **File attachments** - Share images and documents\n• **Voice input** - Speak your queries\n• **Multiple models** - Choose from various AI models\n• **Conversation history** - Access your past chats\n\nWhat feature would you like to explore?',
|
||||
'Here are some things you can do with Conduit:\n\n1. **Chat with AI** - Have natural conversations\n2. **Share files** - Upload images and documents for analysis\n3. **Use voice** - Tap the microphone for hands-free input\n4. **Switch models** - Try different AI models for varied responses\n5. **Search history** - Find past conversations easily\n\nWhich capability interests you most?',
|
||||
],
|
||||
'attachments': [
|
||||
'I see you\'ve shared a file! In Conduit, I can analyze:\n\n• **Images** - Describe, analyze, or answer questions about pictures\n• **Documents** - Review and summarize text files\n• **Code files** - Help debug or explain code\n\nThe file "{filename}" has been received. What would you like me to do with it?',
|
||||
'Thank you for sharing "{filename}"! I can help you:\n\n• Extract information\n• Analyze content\n• Answer questions about it\n• Provide summaries\n\nWhat specific aspect would you like me to focus on?',
|
||||
],
|
||||
'voice': [
|
||||
'Great! You\'re using voice input. This feature allows for hands-free interaction with Conduit. I heard: "{transcript}"\n\nVoice input is perfect for:\n• Quick queries\n• Accessibility\n• Multitasking\n\nHow else can I help you?',
|
||||
'I received your voice message: "{transcript}"\n\nVoice input makes conversations more natural and convenient. Feel free to continue speaking or typing - whatever works best for you!',
|
||||
],
|
||||
'general': [
|
||||
'That\'s an interesting question! Let me think about "{query}".\n\nIn Conduit, you can explore various topics and get detailed responses. The app is designed to be your AI companion for learning, creating, and problem-solving.\n\n(Demo Mode: These are sample responses for app review)',
|
||||
'Regarding "{query}", here\'s what I can share:\n\nConduit provides a seamless chat experience with advanced AI capabilities. Whether you\'re looking for information, creative assistance, or technical help, I\'m here to support you.\n\nNote: This is a demo response - actual usage requires your own AI server.',
|
||||
'I understand you\'re asking about "{query}". \n\nThis demo shows how Conduit handles conversations. In real use, you\'d connect to your own AI server for actual AI responses.\n\nTry uploading an image or using voice input to see more features!',
|
||||
],
|
||||
'error': [
|
||||
'I noticed there might be an issue. In a production environment, Conduit handles errors gracefully and provides helpful feedback. This demo mode simulates that experience.\n\nPlease try your request again, or let me know how I can help differently!',
|
||||
'It seems something unexpected happened. Conduit is designed to recover smoothly from errors and continue providing assistance.\n\nWould you like to try a different query or explore another feature?',
|
||||
],
|
||||
};
|
||||
|
||||
static String generateResponse({
|
||||
required String userMessage,
|
||||
String? filename,
|
||||
bool isVoiceInput = false,
|
||||
}) {
|
||||
final lowerMessage = userMessage.toLowerCase();
|
||||
|
||||
// Determine response category
|
||||
String category = 'general';
|
||||
|
||||
if (lowerMessage.contains('hello') ||
|
||||
lowerMessage.contains('hi') ||
|
||||
lowerMessage.contains('hey') ||
|
||||
lowerMessage.contains('greet')) {
|
||||
category = 'greeting';
|
||||
} else if (lowerMessage.contains('code') ||
|
||||
lowerMessage.contains('program') ||
|
||||
lowerMessage.contains('function') ||
|
||||
lowerMessage.contains('debug')) {
|
||||
category = 'code';
|
||||
} else if (lowerMessage.contains('feature') ||
|
||||
lowerMessage.contains('capability') ||
|
||||
lowerMessage.contains('what can') ||
|
||||
lowerMessage.contains('help')) {
|
||||
category = 'features';
|
||||
} else if (filename != null) {
|
||||
category = 'attachments';
|
||||
} else if (isVoiceInput) {
|
||||
category = 'voice';
|
||||
}
|
||||
|
||||
// Get responses for category
|
||||
final responses = _cannedResponses[category] ?? _cannedResponses['general']!;
|
||||
final response = responses[_random.nextInt(responses.length)];
|
||||
|
||||
// Replace placeholders
|
||||
return response
|
||||
.replaceAll('{query}', userMessage)
|
||||
.replaceAll('{filename}', filename ?? 'file')
|
||||
.replaceAll('{transcript}', userMessage);
|
||||
}
|
||||
|
||||
static String generateStreamingResponse({
|
||||
required String userMessage,
|
||||
String? filename,
|
||||
bool isVoiceInput = false,
|
||||
}) {
|
||||
// For streaming, we'll return the same response but the UI will handle chunking
|
||||
return generateResponse(
|
||||
userMessage: userMessage,
|
||||
filename: filename,
|
||||
isVoiceInput: isVoiceInput,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import '../../../core/models/model.dart';
|
||||
import '../../../shared/widgets/loading_states.dart';
|
||||
import 'chat_page_helpers.dart';
|
||||
import '../../../shared/widgets/themed_dialogs.dart';
|
||||
import '../../onboarding/views/onboarding_sheet.dart';
|
||||
|
||||
class ChatPage extends ConsumerStatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
@@ -58,12 +59,172 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
return display;
|
||||
}
|
||||
|
||||
bool validateFileCount(int currentCount, int newCount, int maxCount) {
|
||||
return (currentCount + newCount) <= maxCount;
|
||||
}
|
||||
|
||||
bool validateFileSize(int fileSize, int maxSizeMB) {
|
||||
return fileSize <= (maxSizeMB * 1024 * 1024);
|
||||
}
|
||||
|
||||
void startNewChat() {
|
||||
// Clear current conversation
|
||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||
ref.read(activeConversationProvider.notifier).state = null;
|
||||
|
||||
// Scroll to top
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(0);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAndAutoSelectModel() async {
|
||||
// Check if a model is already selected
|
||||
final selectedModel = ref.read(selectedModelProvider);
|
||||
if (selectedModel != null) {
|
||||
debugPrint('DEBUG: Model already selected: ${selectedModel.name}');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: No model selected, attempting auto-selection');
|
||||
|
||||
try {
|
||||
// First ensure models are loaded
|
||||
final modelsAsync = ref.read(modelsProvider);
|
||||
List<Model> models;
|
||||
|
||||
if (modelsAsync.hasValue) {
|
||||
models = modelsAsync.value!;
|
||||
} else {
|
||||
debugPrint('DEBUG: Models not loaded yet, fetching...');
|
||||
models = await ref.read(modelsProvider.future);
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Found ${models.length} models available');
|
||||
|
||||
if (models.isEmpty) {
|
||||
debugPrint('DEBUG: No models available for selection');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to use the default model provider
|
||||
try {
|
||||
final model = await ref.read(defaultModelProvider.future);
|
||||
if (model != null) {
|
||||
debugPrint('DEBUG: Model auto-selected via provider: ${model.name}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Default provider failed, selecting first model directly');
|
||||
// Fallback: select the first available model
|
||||
ref.read(selectedModelProvider.notifier).state = models.first;
|
||||
debugPrint('DEBUG: Fallback model selected: ${models.first.name}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to auto-select model: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkAndShowOnboarding() async {
|
||||
try {
|
||||
// Check if onboarding has been seen
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
final seen = await storage.getOnboardingSeen();
|
||||
debugPrint('DEBUG: Chat page - Onboarding seen status: $seen');
|
||||
|
||||
if (!seen && mounted) {
|
||||
// Small delay to ensure navigation has settled
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
if (!mounted) return;
|
||||
|
||||
debugPrint('DEBUG: Showing onboarding from chat page');
|
||||
_showOnboarding();
|
||||
await storage.setOnboardingSeen(true);
|
||||
debugPrint('DEBUG: Onboarding marked as seen');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error checking onboarding status: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _showOnboarding() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.transparent,
|
||||
isScrollControlled: true,
|
||||
builder: (context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.surfaceBackground,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppBorderRadius.modal),
|
||||
),
|
||||
boxShadow: ConduitShadows.modal,
|
||||
),
|
||||
child: const OnboardingSheet(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkAndLoadDemoConversation() async {
|
||||
final isReviewerMode = ref.read(reviewerModeProvider);
|
||||
if (!isReviewerMode) return;
|
||||
|
||||
// Check if there's already an active conversation
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
if (activeConversation != null) {
|
||||
debugPrint('Conversation already active: ${activeConversation.title}');
|
||||
return;
|
||||
}
|
||||
|
||||
// Force refresh conversations provider to ensure we get the demo conversations
|
||||
ref.invalidate(conversationsProvider);
|
||||
|
||||
// Try to load demo conversation
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final conversationsAsync = ref.read(conversationsProvider);
|
||||
|
||||
if (conversationsAsync.hasValue && conversationsAsync.value!.isNotEmpty) {
|
||||
// Find and load the welcome conversation
|
||||
final welcomeConv = conversationsAsync.value!.firstWhere(
|
||||
(conv) => conv.id == 'demo-conv-1',
|
||||
orElse: () => conversationsAsync.value!.first,
|
||||
);
|
||||
|
||||
ref.read(activeConversationProvider.notifier).state = welcomeConv;
|
||||
debugPrint('Auto-loaded demo conversation: ${welcomeConv.title}');
|
||||
return;
|
||||
}
|
||||
|
||||
// If conversations are still loading, wait a bit and retry
|
||||
if (conversationsAsync.isLoading || i == 0) {
|
||||
await Future.delayed(const Duration(milliseconds: 200));
|
||||
continue;
|
||||
}
|
||||
|
||||
// If there was an error or no conversations, break
|
||||
break;
|
||||
}
|
||||
|
||||
debugPrint('Failed to auto-load demo conversation');
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Listen to scroll events to show/hide scroll to bottom button
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
// Initialize chat page components
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
// First, ensure a model is selected
|
||||
await _checkAndAutoSelectModel();
|
||||
|
||||
// Then check for demo conversation in reviewer mode
|
||||
await _checkAndLoadDemoConversation();
|
||||
|
||||
// Finally, show onboarding if needed
|
||||
await _checkAndShowOnboarding();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -89,8 +250,9 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
}
|
||||
|
||||
final isOnline = ref.read(isOnlineProvider);
|
||||
debugPrint('DEBUG: Online status: $isOnline');
|
||||
if (!isOnline) {
|
||||
final isReviewerMode = ref.read(reviewerModeProvider);
|
||||
debugPrint('DEBUG: Online status: $isOnline, Reviewer mode: $isReviewerMode');
|
||||
if (!isOnline && !isReviewerMode) {
|
||||
debugPrint('DEBUG: Offline - cannot send message');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -102,7 +264,6 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// TODO: Implement message queueing for offline mode
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -402,7 +563,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
void _handleNewChat() {
|
||||
// Start a new chat using the existing function
|
||||
startNewChat(ref);
|
||||
startNewChat();
|
||||
|
||||
// Hide scroll-to-bottom button for a fresh chat
|
||||
if (mounted) {
|
||||
@@ -1081,6 +1242,16 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
selectedModelProvider.select((model) => model),
|
||||
);
|
||||
|
||||
// Watch reviewer mode and auto-select model if needed
|
||||
final isReviewerMode = ref.watch(reviewerModeProvider);
|
||||
|
||||
// Auto-select model when in reviewer mode with no selection
|
||||
if (isReviewerMode && selectedModel == null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkAndAutoSelectModel();
|
||||
});
|
||||
}
|
||||
|
||||
return ErrorBoundary(
|
||||
child: PopScope(
|
||||
canPop: false,
|
||||
@@ -1181,7 +1352,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
(models) => _showModelDropdown(context, ref, models),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
@@ -1222,6 +1396,34 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (ref.watch(reviewerModeProvider))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: 1.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.success.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.badge),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.success.withValues(alpha: 0.3),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'REVIEWER MODE',
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: context.conduitTheme.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: GestureDetector(
|
||||
onTap: () {
|
||||
@@ -1230,7 +1432,10 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
(models) => _showModelDropdown(context, ref, models),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
@@ -1273,6 +1478,34 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (ref.watch(reviewerModeProvider))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.sm,
|
||||
vertical: 1.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.conduitTheme.success.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.badge),
|
||||
border: Border.all(
|
||||
color: context.conduitTheme.success.withValues(alpha: 0.3),
|
||||
width: BorderWidth.thin,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'REVIEWER MODE',
|
||||
style: AppTypography.captionStyle.copyWith(
|
||||
color: context.conduitTheme.success,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 9,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
if (!_isSelectionMode) ...[
|
||||
@@ -1344,7 +1577,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
|
||||
// Modern Input (root matches input background including safe area)
|
||||
ModernChatInput(
|
||||
enabled: selectedModel != null && isOnline,
|
||||
enabled: selectedModel != null && (isOnline || ref.watch(reviewerModeProvider)),
|
||||
onSendMessage: (text) =>
|
||||
_handleMessageSend(text, selectedModel),
|
||||
onVoiceInput: _handleVoiceInput,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:io';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_animate/flutter_animate.dart';
|
||||
import '../../../shared/theme/app_theme.dart';
|
||||
import '../../../shared/theme/theme_extensions.dart';
|
||||
import '../../../shared/widgets/conduit_components.dart';
|
||||
import '../../../shared/utils/ui_utils.dart';
|
||||
|
||||
@@ -42,6 +42,7 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
static final _showArchivedProvider = StateProvider<bool>((ref) => false);
|
||||
|
||||
// Provider for folder expansion state (Map<folderId, isExpanded>)
|
||||
// Start with folders expanded by default for better discoverability
|
||||
static final _expandedFoldersProvider = StateProvider<Map<String, bool>>((ref) => {});
|
||||
|
||||
@override
|
||||
@@ -236,6 +237,7 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
Widget _buildConversationsList() {
|
||||
return Consumer(
|
||||
builder: (context, ref, child) {
|
||||
// Use ref.watch to properly react to changes
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
|
||||
return conversationsAsync.when(
|
||||
@@ -250,30 +252,37 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
return _buildNoResultsState();
|
||||
}
|
||||
|
||||
// Deduplicate by ID as a safety measure in case provider has duplicates
|
||||
final deduplicatedConversations = <String, dynamic>{};
|
||||
for (final conv in filteredConversations) {
|
||||
deduplicatedConversations[conv.id] = conv;
|
||||
}
|
||||
final uniqueConversations = deduplicatedConversations.values.toList();
|
||||
|
||||
// Separate conversations by status and folder
|
||||
final pinnedConversations = filteredConversations
|
||||
final pinnedConversations = uniqueConversations
|
||||
.where((c) => c.pinned == true)
|
||||
.toList();
|
||||
final regularConversations = filteredConversations
|
||||
final regularConversations = uniqueConversations
|
||||
.where((c) => c.pinned != true && c.archived != true && (c.folderId == null || c.folderId!.isEmpty))
|
||||
.toList();
|
||||
final folderConversations = filteredConversations
|
||||
final folderConversations = uniqueConversations
|
||||
.where((c) => c.pinned != true && c.archived != true && c.folderId != null && c.folderId!.isNotEmpty)
|
||||
.toList();
|
||||
final archivedConversations = filteredConversations
|
||||
final archivedConversations = uniqueConversations
|
||||
.where((c) => c.archived == true)
|
||||
.toList();
|
||||
|
||||
// Debug logging
|
||||
print('🔍 DEBUG: Total conversations: ${filteredConversations.length}');
|
||||
print('🔍 DEBUG: Total conversations: ${uniqueConversations.length} (filtered: ${filteredConversations.length}, original: ${conversations.length})');
|
||||
print('🔍 DEBUG: Pinned: ${pinnedConversations.length}');
|
||||
print('🔍 DEBUG: Regular: ${regularConversations.length}');
|
||||
print('🔍 DEBUG: Folder: ${folderConversations.length}');
|
||||
print('🔍 DEBUG: Archived: ${archivedConversations.length}');
|
||||
|
||||
// Check first few conversations for folder IDs
|
||||
for (int i = 0; i < filteredConversations.take(5).length; i++) {
|
||||
final conv = filteredConversations[i];
|
||||
for (int i = 0; i < uniqueConversations.take(5).length; i++) {
|
||||
final conv = uniqueConversations[i];
|
||||
print('🔍 DEBUG: Conv ${i}: id=${conv.id.substring(0, 8)}, folderId=${conv.folderId}, pinned=${conv.pinned}, archived=${conv.archived}');
|
||||
}
|
||||
|
||||
@@ -357,8 +366,10 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
Widget _wrapWithRefresh(Widget child) {
|
||||
return ConduitRefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// Invalidate to force a fresh fetch
|
||||
ref.invalidate(conversationsProvider);
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
// Wait for the provider to complete
|
||||
await ref.read(conversationsProvider.future);
|
||||
},
|
||||
child: child,
|
||||
);
|
||||
@@ -1471,14 +1482,14 @@ class _ChatsListPageState extends ConsumerState<ChatsListPage>
|
||||
final expandedFolders = ref.watch(_expandedFoldersProvider);
|
||||
final isExpanded = expandedFolders[folderId] ?? false;
|
||||
|
||||
return GestureDetector(
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
final currentState = ref.read(_expandedFoldersProvider);
|
||||
ref.read(_expandedFoldersProvider.notifier).state = {
|
||||
...currentState,
|
||||
folderId: !isExpanded,
|
||||
};
|
||||
final newState = Map<String, bool>.from(currentState);
|
||||
newState[folderId] = !isExpanded;
|
||||
ref.read(_expandedFoldersProvider.notifier).state = newState;
|
||||
},
|
||||
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Spacing.md,
|
||||
|
||||
Reference in New Issue
Block a user