fix: chats syncing to server
45
README.md
@@ -1,21 +1,48 @@
|
||||
# Conduit - Open-WebUI Mobile Client
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" alt="Screenshot 1" width="250" />
|
||||
|
||||
</p>
|
||||
|
||||
Conduit is an open-source, cross-platform mobile application for Open-WebUI, providing a native mobile experience for interacting with your self-hosted AI infrastructure.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Features
|
||||
- ✅ **Real-time Chat**: Stream responses from AI models in real-time
|
||||
- ✅ **Model Selection**: Choose from available models on your server
|
||||
- ✅ **Conversation Management**: Create, search, and manage chat histories
|
||||
- ✅ **Markdown Rendering**: Full markdown support with syntax highlighting
|
||||
- ✅ **Theme Support**: Light, Dark, and System themes
|
||||
- **Real-time Chat**: Stream responses from AI models in real-time
|
||||
- **Model Selection**: Choose from available models on your server
|
||||
- **Conversation Management**: Create, search, and manage chat histories
|
||||
- **Markdown Rendering**: Full markdown support with syntax highlighting
|
||||
- **Theme Support**: Light, Dark, and System themes
|
||||
|
||||
### Advanced Features
|
||||
- ✅ **Voice Input**: Use speech-to-text for hands-free interaction
|
||||
- ✅ **File Uploads**: Support for images and documents (RAG)
|
||||
- ✅ **Multi-modal Support**: Work with vision models
|
||||
- ✅ **Secure Storage**: Credentials stored securely using platform keychains
|
||||
- **Voice Input**: Use speech-to-text for hands-free interaction
|
||||
- **File Uploads**: Support for images and documents (RAG)
|
||||
- **Multi-modal Support**: Work with vision models
|
||||
- **Secure Storage**: Credentials stored securely using platform keychains
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" alt="Screenshot 2" width="200" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/3.png" alt="Screenshot 3" width="200" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/4.png" alt="Screenshot 4" width="200" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/5.png" alt="Screenshot 5" width="200" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/6.png" alt="Screenshot 6" width="200" />
|
||||
</p>
|
||||
|
||||
## Requirements
|
||||
|
||||
|
||||
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 248 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/4.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/5.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
fastlane/metadata/android/en-US/images/phoneScreenshots/6.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 167 KiB |
|
Before Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 165 KiB |
|
Before Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 113 KiB |
@@ -464,9 +464,15 @@ class ApiService {
|
||||
|
||||
// Parse full OpenWebUI chat with messages
|
||||
Conversation _parseFullOpenWebUIChat(Map<String, dynamic> chatData) {
|
||||
debugPrint('DEBUG: Parsing full OpenWebUI chat data');
|
||||
debugPrint('DEBUG: Chat data keys: ${chatData.keys.toList()}');
|
||||
|
||||
final id = chatData['id'] as String;
|
||||
final title = chatData['title'] as String;
|
||||
|
||||
debugPrint('DEBUG: Parsed chat ID: $id');
|
||||
debugPrint('DEBUG: Parsed chat title: $title');
|
||||
|
||||
// Safely parse timestamps with validation
|
||||
final updatedAt = _parseTimestamp(chatData['updated_at']);
|
||||
final createdAt = _parseTimestamp(chatData['created_at']);
|
||||
@@ -481,26 +487,33 @@ class ApiService {
|
||||
final chatObject = chatData['chat'] as Map<String, dynamic>?;
|
||||
final messages = <ChatMessage>[];
|
||||
|
||||
// Try multiple locations for messages
|
||||
// Try multiple locations for messages - prefer list format to avoid duplication
|
||||
List? messagesList;
|
||||
Map<String, dynamic>? messagesMap;
|
||||
|
||||
if (chatObject != null) {
|
||||
// Check for messages in chat.history.messages (map format)
|
||||
final history = chatObject['history'] as Map<String, dynamic>?;
|
||||
if (history != null && history['messages'] != null) {
|
||||
messagesMap = history['messages'] as Map<String, dynamic>;
|
||||
debugPrint(
|
||||
'DEBUG: Found ${messagesMap.length} messages in chat.history.messages',
|
||||
);
|
||||
}
|
||||
|
||||
// Check for messages in chat.messages (list format)
|
||||
// Check for messages in chat.messages (list format) - PREFERRED
|
||||
if (chatObject['messages'] != null) {
|
||||
messagesList = chatObject['messages'] as List;
|
||||
debugPrint(
|
||||
'DEBUG: Found ${messagesList.length} messages in chat.messages',
|
||||
);
|
||||
} else {
|
||||
// Fallback: Check for messages in chat.history.messages (map format)
|
||||
final history = chatObject['history'] as Map<String, dynamic>?;
|
||||
if (history != null && history['messages'] != null) {
|
||||
final messagesMap = history['messages'] as Map<String, dynamic>;
|
||||
debugPrint(
|
||||
'DEBUG: Found ${messagesMap.length} messages in chat.history.messages (converting to list)',
|
||||
);
|
||||
|
||||
// Convert map to list format to use common parsing logic
|
||||
messagesList = [];
|
||||
for (final entry in messagesMap.entries) {
|
||||
final msgData = Map<String, dynamic>.from(entry.value as Map<String, dynamic>);
|
||||
msgData['id'] = entry.key; // Use the key as the message ID
|
||||
messagesList.add(msgData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (chatData['messages'] != null) {
|
||||
messagesList = chatData['messages'] as List;
|
||||
@@ -509,42 +522,21 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
// Parse messages from map format (chat.history.messages)
|
||||
if (messagesMap != null) {
|
||||
for (final entry in messagesMap.entries) {
|
||||
try {
|
||||
final msgData = entry.value as Map<String, dynamic>;
|
||||
msgData['id'] = entry.key; // Use the key as the message ID
|
||||
debugPrint(
|
||||
'DEBUG: Parsing message from map: ${entry.key} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}',
|
||||
);
|
||||
// Convert OpenWebUI message format to our ChatMessage format
|
||||
final message = _parseOpenWebUIMessage(msgData);
|
||||
messages.add(message);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully parsed message from map: ${message.id} - ${message.role}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error parsing message from map: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse messages from list format (chat.messages or top-level)
|
||||
// Parse messages from list format only (avoiding duplication)
|
||||
if (messagesList != null) {
|
||||
for (final msgData in messagesList) {
|
||||
try {
|
||||
debugPrint(
|
||||
'DEBUG: Parsing message from list: ${msgData['id']} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}',
|
||||
'DEBUG: Parsing message: ${msgData['id']} - role: ${msgData['role']} - content length: ${msgData['content']?.toString().length ?? 0}',
|
||||
);
|
||||
// Convert OpenWebUI message format to our ChatMessage format
|
||||
final message = _parseOpenWebUIMessage(msgData);
|
||||
messages.add(message);
|
||||
debugPrint(
|
||||
'DEBUG: Successfully parsed message from list: ${message.id} - ${message.role}',
|
||||
'DEBUG: Successfully parsed message: ${message.id} - ${message.role}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Error parsing message from list: $e');
|
||||
debugPrint('DEBUG: Error parsing message: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -607,7 +599,6 @@ class ApiService {
|
||||
);
|
||||
}
|
||||
|
||||
// Create new conversation using OpenWebUI API
|
||||
// Create new conversation using OpenWebUI API
|
||||
Future<Conversation> createConversation({
|
||||
required String title,
|
||||
@@ -618,39 +609,47 @@ class ApiService {
|
||||
debugPrint('DEBUG: Creating new conversation on OpenWebUI server');
|
||||
debugPrint('DEBUG: Title: $title, Messages: ${messages.length}');
|
||||
|
||||
// Convert messages to the new format with proper structure
|
||||
// Build messages with parent-child relationships
|
||||
final Map<String, dynamic> messagesMap = {};
|
||||
final List<Map<String, dynamic>> messagesArray = [];
|
||||
String? currentId;
|
||||
String? previousId;
|
||||
|
||||
for (final msg in messages) {
|
||||
final messageId = msg.id;
|
||||
|
||||
// Build message for history.messages map
|
||||
messagesMap[messageId] = {
|
||||
'id': messageId,
|
||||
'parentId': null,
|
||||
'parentId': previousId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
||||
'files': msg.attachmentIds!.map((attachmentId) {
|
||||
if (attachmentId.startsWith('data:')) {
|
||||
// This is an image data URL
|
||||
return {'type': 'image', 'url': attachmentId};
|
||||
} else {
|
||||
// This is a server file ID
|
||||
return {
|
||||
'type': 'file',
|
||||
'id': attachmentId,
|
||||
'url': '${serverConfig.url}/api/v1/files/$attachmentId',
|
||||
};
|
||||
}
|
||||
}).toList(),
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'models': model != null ? [model] : [],
|
||||
if (msg.role == 'user' && model != null) 'models': [model],
|
||||
};
|
||||
currentId = messageId; // Use the last message as current
|
||||
|
||||
// Update parent's childrenIds if there's a previous message
|
||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||||
}
|
||||
|
||||
// Create the chat data structure matching the expected format
|
||||
// Build message for messages array
|
||||
messagesArray.add({
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
if (msg.role == 'user' && model != null) 'models': [model],
|
||||
});
|
||||
|
||||
previousId = messageId;
|
||||
currentId = messageId;
|
||||
}
|
||||
|
||||
// Create the chat data structure matching OpenWebUI format exactly
|
||||
final chatData = {
|
||||
'chat': {
|
||||
'id': '',
|
||||
@@ -661,44 +660,20 @@ class ApiService {
|
||||
'messages': messagesMap,
|
||||
if (currentId != null) 'currentId': currentId,
|
||||
},
|
||||
'messages': messages
|
||||
.map(
|
||||
(msg) => {
|
||||
'id': msg.id,
|
||||
'parentId': null,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
if (msg.attachmentIds != null && msg.attachmentIds!.isNotEmpty)
|
||||
'files': msg.attachmentIds!.map((attachmentId) {
|
||||
if (attachmentId.startsWith('data:')) {
|
||||
// This is an image data URL
|
||||
return {'type': 'image', 'url': attachmentId};
|
||||
} else {
|
||||
// This is a server file ID
|
||||
return {
|
||||
'type': 'file',
|
||||
'id': attachmentId,
|
||||
'url': '${serverConfig.url}/api/v1/files/$attachmentId',
|
||||
};
|
||||
}
|
||||
}).toList(),
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
'models': model != null ? [model] : [],
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'messages': messagesArray,
|
||||
'tags': [],
|
||||
'timestamp': DateTime.now().millisecondsSinceEpoch,
|
||||
},
|
||||
'folder_id': null,
|
||||
};
|
||||
|
||||
debugPrint('DEBUG: Sending chat data: $chatData');
|
||||
debugPrint('DEBUG: Sending chat data with proper parent-child structure');
|
||||
debugPrint('DEBUG: Request data: ${chatData}');
|
||||
|
||||
final response = await _dio.post('/api/v1/chats/new', data: chatData);
|
||||
|
||||
debugPrint('DEBUG: Create conversation response: ${response.data}');
|
||||
debugPrint('DEBUG: Create conversation response status: ${response.statusCode}');
|
||||
debugPrint('DEBUG: Create conversation response data: ${response.data}');
|
||||
|
||||
// Parse the response
|
||||
final responseData = response.data as Map<String, dynamic>;
|
||||
@@ -717,30 +692,71 @@ class ApiService {
|
||||
'DEBUG: Updating conversation $conversationId with ${messages.length} messages',
|
||||
);
|
||||
|
||||
// Convert messages to OpenWebUI format
|
||||
final openWebUIMessages = messages
|
||||
.map(
|
||||
(msg) => {
|
||||
// Build messages map and array in OpenWebUI format
|
||||
final Map<String, dynamic> messagesMap = {};
|
||||
final List<Map<String, dynamic>> messagesArray = [];
|
||||
String? currentId;
|
||||
String? previousId;
|
||||
|
||||
for (final msg in messages) {
|
||||
final messageId = msg.id;
|
||||
|
||||
// Build message for messages map (history.messages)
|
||||
messagesMap[messageId] = {
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'childrenIds': <String>[],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
if (msg.model != null) 'model': msg.model,
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
||||
if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model,
|
||||
if (msg.role == 'assistant') 'modelIdx': 0,
|
||||
if (msg.role == 'assistant') 'done': true,
|
||||
if (msg.role == 'user' && model != null) 'models': [model],
|
||||
};
|
||||
|
||||
// Create the chat data structure
|
||||
// Update parent's childrenIds
|
||||
if (previousId != null && messagesMap.containsKey(previousId)) {
|
||||
(messagesMap[previousId]['childrenIds'] as List).add(messageId);
|
||||
}
|
||||
|
||||
// Build message for messages array
|
||||
messagesArray.add({
|
||||
'id': messageId,
|
||||
'parentId': previousId,
|
||||
'childrenIds': [],
|
||||
'role': msg.role,
|
||||
'content': msg.content,
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
if (msg.role == 'assistant' && msg.model != null) 'model': msg.model,
|
||||
if (msg.role == 'assistant' && msg.model != null) 'modelName': msg.model,
|
||||
if (msg.role == 'assistant') 'modelIdx': 0,
|
||||
if (msg.role == 'assistant') 'done': true,
|
||||
if (msg.role == 'user' && model != null) 'models': [model],
|
||||
});
|
||||
|
||||
previousId = messageId;
|
||||
currentId = messageId;
|
||||
}
|
||||
|
||||
// Create the chat data structure matching OpenWebUI format exactly
|
||||
final chatData = {
|
||||
'chat': {
|
||||
'messages': openWebUIMessages,
|
||||
if (title != null) 'title': title,
|
||||
if (model != null) 'model': model,
|
||||
if (systemPrompt != null) 'system': systemPrompt,
|
||||
'models': model != null ? [model] : [],
|
||||
'messages': messagesArray,
|
||||
'history': {
|
||||
'messages': messagesMap,
|
||||
if (currentId != null) 'currentId': currentId,
|
||||
},
|
||||
'params': {},
|
||||
'files': [],
|
||||
},
|
||||
};
|
||||
|
||||
debugPrint('DEBUG: Updating chat with data: $chatData');
|
||||
debugPrint('DEBUG: Updating chat with OpenWebUI format data using POST');
|
||||
|
||||
// OpenWebUI uses POST not PUT for updating chats
|
||||
final response = await _dio.post(
|
||||
'/api/v1/chats/$conversationId',
|
||||
data: chatData,
|
||||
@@ -1352,18 +1368,41 @@ class ApiService {
|
||||
Map<String, dynamic>? modelItem,
|
||||
String? sessionId,
|
||||
}) async {
|
||||
debugPrint('DEBUG: Sending chat completed for message: $messageId');
|
||||
debugPrint('DEBUG: Sending chat completed notification (optional endpoint)');
|
||||
|
||||
final requestData = {
|
||||
'model': model,
|
||||
'messages': messages,
|
||||
if (modelItem != null) 'model_item': modelItem,
|
||||
'chat_id': chatId,
|
||||
if (sessionId != null) 'session_id': sessionId,
|
||||
'id': messageId,
|
||||
// This endpoint appears to be optional or deprecated in newer OpenWebUI versions
|
||||
// The main chat synchronization happens through /api/v1/chats/{id} updates
|
||||
// We'll still try to call it but won't fail if it doesn't work
|
||||
|
||||
// Format messages to match OpenWebUI expected structure
|
||||
// Note: Removing 'id' field as it causes 400 error
|
||||
final formattedMessages = messages.map((msg) {
|
||||
final formatted = {
|
||||
// Don't include 'id' - it causes 400 error with detail: 'id'
|
||||
'role': msg['role'],
|
||||
'content': msg['content'],
|
||||
'timestamp': msg['timestamp'] ?? DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
};
|
||||
|
||||
debugPrint('DEBUG: Chat completed request data: $requestData');
|
||||
// Add model info for assistant messages
|
||||
if (msg['role'] == 'assistant') {
|
||||
formatted['model'] = model;
|
||||
if (msg.containsKey('usage')) {
|
||||
formatted['usage'] = msg['usage'];
|
||||
}
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}).toList();
|
||||
|
||||
// Include the message ID at the top level - server expects this
|
||||
final requestData = {
|
||||
'id': messageId, // The server expects the assistant message ID here
|
||||
'chat_id': chatId,
|
||||
'model': model,
|
||||
'messages': formattedMessages,
|
||||
// Don't include model_item as it might not be expected
|
||||
};
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
@@ -1372,8 +1411,8 @@ class ApiService {
|
||||
);
|
||||
debugPrint('DEBUG: Chat completed response: ${response.statusCode}');
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Chat completed error: $e');
|
||||
// Non-critical error, don't throw
|
||||
// This is a non-critical endpoint - main sync happens via /api/v1/chats/{id}
|
||||
debugPrint('DEBUG: Chat completed endpoint not available or failed (non-critical): $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2288,18 +2327,31 @@ class ApiService {
|
||||
|
||||
// Build request data (exactly like OpenWebUI)
|
||||
final data = {
|
||||
'stream': true,
|
||||
'model': model,
|
||||
'messages': processedMessages,
|
||||
'stream': true, // Always enable streaming
|
||||
'max_tokens': null, // Let the model decide
|
||||
'temperature': 0.8,
|
||||
'top_p': 0.9,
|
||||
'frequency_penalty': 0.0,
|
||||
'presence_penalty': 0.0,
|
||||
'params': {},
|
||||
'tool_servers': [],
|
||||
'features': {
|
||||
'image_generation': false,
|
||||
'code_interpreter': false,
|
||||
'web_search': enableWebSearch,
|
||||
'memory': false,
|
||||
},
|
||||
'variables': {
|
||||
'{{USER_NAME}}': 'User',
|
||||
'{{USER_LOCATION}}': 'Unknown',
|
||||
'{{CURRENT_DATETIME}}': DateTime.now().toIso8601String().substring(0, 19).replaceAll('T', ' '),
|
||||
'{{CURRENT_DATE}}': DateTime.now().toIso8601String().substring(0, 10),
|
||||
'{{CURRENT_TIME}}': DateTime.now().toIso8601String().substring(11, 19),
|
||||
'{{CURRENT_WEEKDAY}}': _getCurrentWeekday(),
|
||||
'{{CURRENT_TIMEZONE}}': DateTime.now().timeZoneName,
|
||||
'{{USER_LANGUAGE}}': 'en-US',
|
||||
},
|
||||
if (modelItem != null) 'model_item': modelItem,
|
||||
if (conversationId != null) 'chat_id': conversationId,
|
||||
if (tools != null && tools.isNotEmpty) 'tools': tools,
|
||||
if (allFiles.isNotEmpty) 'files': allFiles,
|
||||
if (enableWebSearch) 'web_search': enableWebSearch,
|
||||
};
|
||||
|
||||
debugPrint('DEBUG: Starting SSE streaming request');
|
||||
|
||||
@@ -42,6 +42,12 @@ class FieldMapper {
|
||||
'file_size': 'fileSize',
|
||||
'file_type': 'fileType',
|
||||
'mime_type': 'mimeType',
|
||||
// OpenWebUI chat message fields - keep in camelCase
|
||||
'parentId': 'parentId',
|
||||
'childrenIds': 'childrenIds',
|
||||
'currentId': 'currentId',
|
||||
'modelName': 'modelName',
|
||||
'modelIdx': 'modelIdx',
|
||||
};
|
||||
|
||||
static const Map<String, String> _specialClientToApi = {
|
||||
@@ -74,6 +80,12 @@ class FieldMapper {
|
||||
'fileSize': 'file_size',
|
||||
'fileType': 'file_type',
|
||||
'mimeType': 'mime_type',
|
||||
// OpenWebUI chat message fields - keep in camelCase
|
||||
'parentId': 'parentId',
|
||||
'childrenIds': 'childrenIds',
|
||||
'currentId': 'currentId',
|
||||
'modelName': 'modelName',
|
||||
'modelIdx': 'modelIdx',
|
||||
};
|
||||
|
||||
/// Transform data from client format (camelCase) to API format (snake_case)
|
||||
|
||||
@@ -378,13 +378,18 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
|
||||
const SizedBox(height: Spacing.sm),
|
||||
|
||||
AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AccessibleFormField(
|
||||
label: 'Server address',
|
||||
hint: 'https://server',
|
||||
controller: _urlController,
|
||||
validator: InputValidationService.combine([
|
||||
InputValidationService.validateRequired,
|
||||
(value) => InputValidationService.validateUrl(
|
||||
(value) =>
|
||||
InputValidationService.validateUrl(
|
||||
value,
|
||||
required: true,
|
||||
),
|
||||
@@ -394,9 +399,12 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
'Enter your server URL or IP address',
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
prefixIcon: Icon(
|
||||
isIOS ? CupertinoIcons.globe : Icons.public,
|
||||
isIOS
|
||||
? CupertinoIcons.globe
|
||||
: Icons.public,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
autofillHints: const [AutofillHints.url],
|
||||
).animate().slideX(
|
||||
begin: -0.08,
|
||||
duration: AnimationDuration.messageSlide,
|
||||
@@ -447,7 +455,8 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
child: Text(
|
||||
server.url,
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
overflow:
|
||||
TextOverflow.ellipsis,
|
||||
style: context
|
||||
.conduitTheme
|
||||
.bodySmall
|
||||
@@ -478,13 +487,18 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
),
|
||||
]),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
semanticLabel: 'Enter your username or email',
|
||||
semanticLabel:
|
||||
'Enter your username or email',
|
||||
prefixIcon: Icon(
|
||||
isIOS
|
||||
? CupertinoIcons.person
|
||||
: Icons.person_outline,
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
),
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: Spacing.comfortable),
|
||||
@@ -519,13 +533,17 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
: (isIOS
|
||||
? CupertinoIcons.eye
|
||||
: Icons.visibility),
|
||||
color: context.conduitTheme.iconSecondary,
|
||||
color:
|
||||
context.conduitTheme.iconSecondary,
|
||||
),
|
||||
onPressed: () => setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
}),
|
||||
),
|
||||
onSubmitted: (_) => _connectAndSignIn(),
|
||||
autofillHints: const [
|
||||
AutofillHints.password,
|
||||
],
|
||||
),
|
||||
|
||||
if (_loginError != null) ...[
|
||||
@@ -555,6 +573,9 @@ class _ConnectAndSignInPageState extends ConsumerState<ConnectAndSignInPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -345,52 +346,9 @@ Future<void> _sendMessageInternal(
|
||||
// Check if we need to create a new conversation first
|
||||
var activeConversation = ref.read(activeConversationProvider);
|
||||
|
||||
if (activeConversation == null) {
|
||||
// Create new conversation locally first to ensure we have a conversation context
|
||||
debugPrint('DEBUG: Creating new conversation before sending message');
|
||||
final title = message.length > 50
|
||||
? '${message.substring(0, 50)}...'
|
||||
: message;
|
||||
debugPrint('DEBUG: Active conversation before send: ${activeConversation?.id}');
|
||||
|
||||
// Create local conversation first
|
||||
final localConversation = Conversation(
|
||||
id: const Uuid().v4(),
|
||||
title: title,
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: [],
|
||||
);
|
||||
|
||||
// Set as active conversation locally
|
||||
ref.read(activeConversationProvider.notifier).state = localConversation;
|
||||
activeConversation = localConversation;
|
||||
|
||||
if (!reviewerMode) {
|
||||
// Try to create on server, but don't fail if it doesn't work
|
||||
try {
|
||||
final serverConversation = await api.createConversation(
|
||||
title: title,
|
||||
messages: <ChatMessage>[],
|
||||
model: selectedModel.id,
|
||||
);
|
||||
final updatedConversation = localConversation.copyWith(
|
||||
id: serverConversation.id,
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state =
|
||||
updatedConversation;
|
||||
activeConversation = updatedConversation;
|
||||
debugPrint(
|
||||
'DEBUG: Created conversation ${serverConversation.id} on server',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to create conversation on server, using local: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add user message
|
||||
// Create user message first
|
||||
debugPrint('DEBUG: Creating user message with attachments: $attachments');
|
||||
final userMessage = ChatMessage(
|
||||
id: const Uuid().v4(),
|
||||
@@ -399,8 +357,68 @@ Future<void> _sendMessageInternal(
|
||||
timestamp: DateTime.now(),
|
||||
attachmentIds: attachments,
|
||||
);
|
||||
|
||||
if (activeConversation == null) {
|
||||
// Create new conversation with the first message included
|
||||
debugPrint('DEBUG: Creating new conversation with first message');
|
||||
|
||||
// Create local conversation first
|
||||
final localConversation = Conversation(
|
||||
id: const Uuid().v4(),
|
||||
title: 'New Chat',
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
messages: [userMessage], // Include the user message
|
||||
);
|
||||
|
||||
// Set as active conversation locally
|
||||
ref.read(activeConversationProvider.notifier).state = localConversation;
|
||||
activeConversation = localConversation;
|
||||
|
||||
if (!reviewerMode) {
|
||||
// Try to create on server with the first message included
|
||||
try {
|
||||
final serverConversation = await api.createConversation(
|
||||
title: 'New Chat',
|
||||
messages: [userMessage], // Include the first message in creation
|
||||
model: selectedModel.id,
|
||||
);
|
||||
final updatedConversation = localConversation.copyWith(
|
||||
id: serverConversation.id,
|
||||
messages: serverConversation.messages.isNotEmpty
|
||||
? serverConversation.messages
|
||||
: [userMessage],
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state =
|
||||
updatedConversation;
|
||||
activeConversation = updatedConversation;
|
||||
|
||||
// Set messages in the messages provider to keep UI in sync
|
||||
ref.read(chatMessagesProvider.notifier).clearMessages();
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
|
||||
debugPrint(
|
||||
'DEBUG: Created conversation ${serverConversation.id} on server with first message',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Server conversation ID: ${serverConversation.id}, Title: ${serverConversation.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint(
|
||||
'DEBUG: Failed to create conversation on server, using local: $e',
|
||||
);
|
||||
// Still add the message locally
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
}
|
||||
} else {
|
||||
// Add message for reviewer mode
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
}
|
||||
} else {
|
||||
// Add user message to existing conversation
|
||||
ref.read(chatMessagesProvider.notifier).addMessage(userMessage);
|
||||
debugPrint('DEBUG: User message added with ID: ${userMessage.id}');
|
||||
}
|
||||
|
||||
// We'll add the assistant message placeholder after we get the message ID from the API (or immediately in reviewer mode)
|
||||
|
||||
@@ -734,8 +752,8 @@ Future<void> _sendMessageInternal(
|
||||
|
||||
onDone: () async {
|
||||
debugPrint('DEBUG: Stream completed in chat provider');
|
||||
// Don't mark streaming as complete yet - wait for server content replacement
|
||||
// ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
// Mark streaming as complete immediately for better UX
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
|
||||
// Send chat completed notification to OpenWebUI
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
@@ -754,18 +772,21 @@ Future<void> _sendMessageInternal(
|
||||
'timestamp': msg.timestamp.millisecondsSinceEpoch ~/ 1000,
|
||||
};
|
||||
|
||||
// Add model if available
|
||||
if (msg.model != null) {
|
||||
messageMap['model'] = msg.model;
|
||||
}
|
||||
// For assistant messages, add completion details
|
||||
if (msg.role == 'assistant') {
|
||||
messageMap['model'] = selectedModel.id;
|
||||
|
||||
// Add sources and usage if available
|
||||
if (msg.sources != null) {
|
||||
messageMap['sources'] = msg.sources;
|
||||
}
|
||||
// Only include usage data if it's actually available from the response
|
||||
// Add mock usage data if not available (OpenWebUI expects this)
|
||||
if (msg.usage != null) {
|
||||
messageMap['usage'] = msg.usage;
|
||||
} else if (msg == messages.last) {
|
||||
// Add basic usage for the last assistant message
|
||||
messageMap['usage'] = {
|
||||
'prompt_tokens': 10,
|
||||
'completion_tokens': msg.content.split(' ').length,
|
||||
'total_tokens': 10 + msg.content.split(' ').length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
formattedMessages.add(messageMap);
|
||||
@@ -776,6 +797,9 @@ Future<void> _sendMessageInternal(
|
||||
debugPrint(
|
||||
'DEBUG: Sending chat completed notification to OpenWebUI',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Active conversation ID: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Chat ID: ${activeConversation.id}, Message ID: $assistantMessageId, Messages: ${formattedMessages.length}',
|
||||
);
|
||||
@@ -788,25 +812,27 @@ Future<void> _sendMessageInternal(
|
||||
sessionId: sessionId, // Include session ID
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Chat completed notification sent successfully',
|
||||
'DEBUG: Chat completed notification sent successfully for chat ID: ${activeConversation.id}',
|
||||
);
|
||||
|
||||
// Give server a moment to process title generation
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Chat completed notification failed: $e');
|
||||
// Continue with title generation even if this fails
|
||||
debugPrint('DEBUG: Error details: $e');
|
||||
// Continue even if this fails - it's non-critical
|
||||
}
|
||||
|
||||
// Only check for title generation on first assistant response (when conversation has <= 2 messages)
|
||||
// Always check for server content updates
|
||||
debugPrint('DEBUG: Checking for server content updates...');
|
||||
// Fetch the latest conversation state without waiting for title generation
|
||||
debugPrint('DEBUG: Fetching latest conversation state...');
|
||||
debugPrint('DEBUG: Current message count: ${messages.length}');
|
||||
|
||||
try {
|
||||
// Quick fetch to get the current state - no waiting for title generation
|
||||
final updatedConv = await api.getConversation(
|
||||
activeConversation.id,
|
||||
);
|
||||
debugPrint('DEBUG: Current title: ${updatedConv.title}');
|
||||
|
||||
// Check for title updates only on first response
|
||||
// Check if we should update the title (only on first response and if server has one)
|
||||
final shouldUpdateTitle =
|
||||
messages.length <= 2 &&
|
||||
updatedConv.title != 'New Chat' &&
|
||||
@@ -963,29 +989,25 @@ Future<void> _sendMessageInternal(
|
||||
);
|
||||
}
|
||||
|
||||
// Now mark streaming as complete since server content has replaced simulated content
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
// Streaming already marked as complete when stream ended
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after server content replacement',
|
||||
'DEBUG: Server content replacement completed',
|
||||
);
|
||||
|
||||
// Start background title check for first message exchanges
|
||||
if (messages.length <= 2 && updatedConv.title == 'New Chat') {
|
||||
debugPrint('DEBUG: Starting background title check...');
|
||||
_checkForTitleInBackground(ref, activeConversation.id);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to fetch server content: $e');
|
||||
// Mark streaming as complete even if server content replacement fails
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after server content replacement failure',
|
||||
);
|
||||
// Streaming already marked as complete when stream ended
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Chat completed error: $e');
|
||||
// Continue without failing the entire process
|
||||
// Note: Conversation still syncs via _saveConversationToServer
|
||||
|
||||
// IMPORTANT: Always mark streaming as complete even if server operations fail
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
debugPrint(
|
||||
'DEBUG: Streaming marked as complete after chat completed error',
|
||||
);
|
||||
// Streaming already marked as complete when stream ended
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -994,8 +1016,8 @@ Future<void> _sendMessageInternal(
|
||||
debugPrint('DEBUG: About to save conversation to server...');
|
||||
// Add a small delay to ensure the last message content is fully updated
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
_saveConversationToServer(ref);
|
||||
debugPrint('DEBUG: Conversation save initiated');
|
||||
await _saveConversationToServer(ref);
|
||||
debugPrint('DEBUG: Conversation save completed');
|
||||
},
|
||||
onError: (error) {
|
||||
debugPrint('DEBUG: Stream error in chat provider: $error');
|
||||
@@ -1153,8 +1175,54 @@ Please try sending the message again, or try without attachments.''',
|
||||
}
|
||||
}
|
||||
|
||||
// These polling functions are no longer needed since we use direct title generation
|
||||
// via /api/v1/tasks/title/completions endpoint
|
||||
// Background function to check for title updates without blocking UI
|
||||
Future<void> _checkForTitleInBackground(dynamic ref, String conversationId) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) return;
|
||||
|
||||
// Wait a bit before first check to give server time to generate
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
|
||||
// Try a few times with increasing delays
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
final updatedConv = await api.getConversation(conversationId);
|
||||
|
||||
if (updatedConv.title != 'New Chat' && updatedConv.title.isNotEmpty) {
|
||||
debugPrint('DEBUG: Background title update found: ${updatedConv.title}');
|
||||
|
||||
// Update the active conversation with the new title
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
if (activeConversation?.id == conversationId) {
|
||||
final updated = activeConversation!.copyWith(
|
||||
title: updatedConv.title,
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
ref.read(activeConversationProvider.notifier).state = updated;
|
||||
|
||||
// Refresh the conversations list
|
||||
ref.invalidate(conversationsProvider);
|
||||
}
|
||||
|
||||
return; // Title found, stop checking
|
||||
}
|
||||
|
||||
// Wait before next check (3s, 5s, 7s)
|
||||
if (i < 2) {
|
||||
await Future.delayed(Duration(seconds: 2 + (i * 2)));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Background title check error: $e');
|
||||
break; // Stop on error
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('DEBUG: Background title check completed without finding generated title');
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Background title check failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Save current conversation to OpenWebUI server
|
||||
Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
@@ -1187,6 +1255,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
debugPrint(
|
||||
'DEBUG: Updating conversation ${activeConversation.id} with complete message history',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Conversation ID being updated: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Number of messages to save: ${messages.length}',
|
||||
);
|
||||
|
||||
try {
|
||||
await api.updateConversationWithMessages(
|
||||
@@ -1205,8 +1279,12 @@ Future<void> _saveConversationToServer(dynamic ref) async {
|
||||
debugPrint(
|
||||
'DEBUG: Successfully updated conversation on server: ${activeConversation.id}',
|
||||
);
|
||||
debugPrint(
|
||||
'DEBUG: Updated conversation title: ${updatedConversation.title}',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to update conversation on server: $e');
|
||||
debugPrint('DEBUG: Error details: $e');
|
||||
// Fallback to local storage if server update fails
|
||||
await _saveConversationLocally(ref);
|
||||
return;
|
||||
@@ -1250,14 +1328,20 @@ Future<void> _saveConversationLocally(dynamic ref) async {
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
if (activeConversation == null) {
|
||||
await storage.addLocalConversation(updatedConversation);
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
// Store conversation locally using the storage service's actual methods
|
||||
final conversationsJson = await storage.getString('conversations') ?? '[]';
|
||||
final List<dynamic> conversations = jsonDecode(conversationsJson);
|
||||
|
||||
// Find and update or add the conversation
|
||||
final existingIndex = conversations.indexWhere((c) => c['id'] == updatedConversation.id);
|
||||
if (existingIndex >= 0) {
|
||||
conversations[existingIndex] = updatedConversation.toJson();
|
||||
} else {
|
||||
await storage.updateLocalConversation(updatedConversation);
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
conversations.add(updatedConversation.toJson());
|
||||
}
|
||||
|
||||
await storage.setString('conversations', jsonEncode(conversations));
|
||||
ref.read(activeConversationProvider.notifier).state = updatedConversation;
|
||||
ref.invalidate(conversationsProvider);
|
||||
} catch (e) {
|
||||
debugPrint('Error saving conversation locally: $e');
|
||||
|
||||
@@ -1075,13 +1075,27 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
// Check if there's unsaved content
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
if (messages.isNotEmpty) {
|
||||
// Check if currently streaming
|
||||
final isStreaming = messages.any((msg) => msg.isStreaming);
|
||||
|
||||
final shouldPop = await NavigationService.confirmNavigation(
|
||||
title: 'Leave Chat?',
|
||||
message: 'Your conversation will be saved.',
|
||||
message: isStreaming
|
||||
? 'The AI is still responding. Leave anyway?'
|
||||
: 'Your conversation will be saved.',
|
||||
confirmText: 'Leave',
|
||||
cancelText: 'Stay',
|
||||
);
|
||||
if (shouldPop && context.mounted) {
|
||||
// If streaming, stop it first
|
||||
if (isStreaming) {
|
||||
ref.read(chatMessagesProvider.notifier).finishStreaming();
|
||||
}
|
||||
|
||||
// Save the conversation before leaving
|
||||
await _saveConversationBeforeLeaving(ref);
|
||||
|
||||
if (context.mounted) {
|
||||
final canPopNavigator = Navigator.of(context).canPop();
|
||||
if (canPopNavigator) {
|
||||
Navigator.of(context).pop();
|
||||
@@ -1089,6 +1103,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (context.mounted) {
|
||||
final canPopNavigator = Navigator.of(context).canPop();
|
||||
if (canPopNavigator) {
|
||||
@@ -1303,8 +1318,13 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
.read(activeConversationProvider.notifier)
|
||||
.state =
|
||||
full;
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to refresh conversation: $e');
|
||||
// Could show a snackbar here if needed
|
||||
}
|
||||
}
|
||||
// Add small delay for better UX feedback
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
},
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
@@ -1372,6 +1392,39 @@ class _ChatPageState extends ConsumerState<ChatPage> {
|
||||
); // ErrorBoundary
|
||||
}
|
||||
|
||||
Future<void> _saveConversationBeforeLeaving(WidgetRef ref) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final messages = ref.read(chatMessagesProvider);
|
||||
final activeConversation = ref.read(activeConversationProvider);
|
||||
final selectedModel = ref.read(selectedModelProvider);
|
||||
|
||||
if (api == null || messages.isEmpty || activeConversation == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the last message (assistant) has content
|
||||
final lastMessage = messages.last;
|
||||
if (lastMessage.role == 'assistant' && lastMessage.content.trim().isEmpty) {
|
||||
// Remove empty assistant message before saving
|
||||
messages.removeLast();
|
||||
if (messages.isEmpty) return;
|
||||
}
|
||||
|
||||
// Update the existing conversation with all messages
|
||||
await api.updateConversationWithMessages(
|
||||
activeConversation.id,
|
||||
messages,
|
||||
model: selectedModel?.id,
|
||||
);
|
||||
|
||||
debugPrint('DEBUG: Conversation saved before leaving');
|
||||
} catch (e) {
|
||||
debugPrint('DEBUG: Failed to save conversation before leaving: $e');
|
||||
// Don't block navigation even if save fails
|
||||
}
|
||||
}
|
||||
|
||||
void _showModelDropdown(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
|
||||
@@ -40,6 +40,16 @@ class _OnboardingSheetState extends State<OnboardingSheet> {
|
||||
'Great for quick notes or long prompts',
|
||||
],
|
||||
),
|
||||
_OnboardingPage(
|
||||
title: 'Quick actions',
|
||||
subtitle:
|
||||
'Long‑press the top‑left menu to open shortcuts like New Chat, Files, and Profile.',
|
||||
icon: CupertinoIcons.line_horizontal_3,
|
||||
bullets: [
|
||||
'Tap to open chats list; long‑press for Quick Actions',
|
||||
'Jump instantly to New Chat, Files, or Profile',
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
void _next() {
|
||||
|
||||
@@ -345,7 +345,11 @@ class ProfilePage extends ConsumerWidget {
|
||||
|
||||
Widget _buildThemeToggleTile(BuildContext context, WidgetRef ref) {
|
||||
final themeMode = ref.watch(themeModeProvider);
|
||||
final isDark = themeMode == ThemeMode.dark;
|
||||
final platformBrightness = MediaQuery.platformBrightnessOf(context);
|
||||
final bool isDarkEffective =
|
||||
themeMode == ThemeMode.dark ||
|
||||
(themeMode == ThemeMode.system &&
|
||||
platformBrightness == Brightness.dark);
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
@@ -377,13 +381,18 @@ class ProfilePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
isDark ? 'Currently using Dark theme' : 'Currently using Light theme',
|
||||
themeMode == ThemeMode.system
|
||||
? 'Following system: '
|
||||
'${platformBrightness == Brightness.dark ? 'Dark' : 'Light'}'
|
||||
: (isDarkEffective
|
||||
? 'Currently using Dark theme'
|
||||
: 'Currently using Light theme'),
|
||||
style: context.conduitTheme.bodySmall?.copyWith(
|
||||
color: context.conduitTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: Switch.adaptive(
|
||||
value: isDark,
|
||||
value: isDarkEffective,
|
||||
onChanged: (value) {
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
@@ -391,7 +400,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
},
|
||||
),
|
||||
onTap: () {
|
||||
final newValue = !isDark;
|
||||
final newValue = !isDarkEffective;
|
||||
ref
|
||||
.read(themeModeProvider.notifier)
|
||||
.setTheme(newValue ? ThemeMode.dark : ThemeMode.light);
|
||||
|
||||
@@ -710,6 +710,7 @@ class AccessibleFormField extends StatelessWidget {
|
||||
final String? Function(String?)? validator;
|
||||
final bool isRequired;
|
||||
final bool isCompact;
|
||||
final Iterable<String>? autofillHints;
|
||||
|
||||
const AccessibleFormField({
|
||||
super.key,
|
||||
@@ -731,6 +732,7 @@ class AccessibleFormField extends StatelessWidget {
|
||||
this.validator,
|
||||
this.isRequired = false,
|
||||
this.isCompact = false,
|
||||
this.autofillHints,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -776,6 +778,7 @@ class AccessibleFormField extends StatelessWidget {
|
||||
keyboardType: keyboardType,
|
||||
autofocus: autofocus,
|
||||
validator: validator,
|
||||
autofillHints: autofillHints,
|
||||
style: AppTypography.standard.copyWith(
|
||||
color: context.conduitTheme.textPrimary,
|
||||
),
|
||||
|
||||