From d3bc5f1dd0c79c6f3a5544583885590a396c3dac Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 16 Jul 2025 22:39:08 +0530 Subject: [PATCH 1/9] multiple chat sessions db ops --- backend/database/chat.py | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/backend/database/chat.py b/backend/database/chat.py index eb1346643f4..304d5c9b61f 100644 --- a/backend/database/chat.py +++ b/backend/database/chat.py @@ -357,6 +357,59 @@ def add_files_to_chat_session(uid: str, chat_session_id: str, file_ids: List[str session_ref.update({"file_ids": firestore.ArrayUnion(file_ids)}) +def get_chat_sessions(uid: str, app_id: Optional[str] = None) -> List[dict]: + """Get all chat sessions for a user and app""" + session_ref = ( + db.collection('users') + .document(uid) + .collection('chat_sessions') + .where(filter=FieldFilter('plugin_id', '==', app_id)) + .order_by('created_at', direction=firestore.Query.DESCENDING) + ) + return [doc.to_dict() for doc in session_ref.stream()] + + +def get_chat_session_by_id(uid: str, chat_session_id: str) -> Optional[dict]: + """Get a specific chat session by ID""" + user_ref = db.collection('users').document(uid) + session_ref = user_ref.collection('chat_sessions').document(chat_session_id) + session_doc = session_ref.get() + + if session_doc.exists: + return session_doc.to_dict() + return None + + +def create_new_chat_session(uid: str, plugin_id: Optional[str] = None, title: Optional[str] = None) -> dict: + """Create a new chat session""" + from datetime import datetime, timezone + import uuid + + # Generate a temporary title if not provided + if not title: + session_count = len(get_chat_sessions(uid, plugin_id)) + 1 + title = f"New Chat {session_count}" + + session_data = { + 'id': str(uuid.uuid4()), + 'created_at': datetime.now(timezone.utc), + 'plugin_id': plugin_id, + 'app_id': plugin_id, # For backward compatibility + 'message_ids': [], + 'file_ids': [], + 'title': title + } + + return add_chat_session(uid, session_data) + + +def update_chat_session_title(uid: str, chat_session_id: str, title: str): + """Update the title of a chat session""" + user_ref = db.collection('users').document(uid) + session_ref = user_ref.collection('chat_sessions').document(chat_session_id) + session_ref.update({"title": title}) + + # ************************************** # ********* MIGRATION HELPERS ********** # ************************************** From 362bf55af48dbd2544314308b248dba06f920cb6 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 17 Jul 2025 12:45:55 +0530 Subject: [PATCH 2/9] chat session handling backend --- backend/database/chat.py | 8 ++-- backend/models/chat.py | 5 +++ backend/routers/chat.py | 94 +++++++++++++++++++++++++++++++++------- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/backend/database/chat.py b/backend/database/chat.py index 304d5c9b61f..a0a1ca63b31 100644 --- a/backend/database/chat.py +++ b/backend/database/chat.py @@ -380,21 +380,21 @@ def get_chat_session_by_id(uid: str, chat_session_id: str) -> Optional[dict]: return None -def create_new_chat_session(uid: str, plugin_id: Optional[str] = None, title: Optional[str] = None) -> dict: +def create_new_chat_session(uid: str, app_id: Optional[str] = None, title: Optional[str] = None) -> dict: """Create a new chat session""" from datetime import datetime, timezone import uuid # Generate a temporary title if not provided if not title: - session_count = len(get_chat_sessions(uid, plugin_id)) + 1 + session_count = len(get_chat_sessions(uid, app_id)) + 1 title = f"New Chat {session_count}" session_data = { 'id': str(uuid.uuid4()), 'created_at': datetime.now(timezone.utc), - 'plugin_id': plugin_id, - 'app_id': plugin_id, # For backward compatibility + 'plugin_id': app_id, + 'app_id': app_id, 'message_ids': [], 'file_ids': [], 'title': title diff --git a/backend/models/chat.py b/backend/models/chat.py index 3167e9fb731..3bcfebe8a10 100644 --- a/backend/models/chat.py +++ b/backend/models/chat.py @@ -153,6 +153,7 @@ class ChatSession(BaseModel): app_id: Optional[str] = None plugin_id: Optional[str] = None created_at: datetime + title: Optional[str] = None @model_validator(mode='before') @classmethod @@ -177,3 +178,7 @@ def add_file_ids(self, new_file_ids: List[str]): def retrieve_new_file(self, file_ids) -> List: existing_files = set(self.file_ids or []) return list(set(file_ids) - existing_files) + + +class UpdateChatSessionTitleRequest(BaseModel): + title: str diff --git a/backend/routers/chat.py b/backend/routers/chat.py index 280dde1d696..af3f04b5351 100644 --- a/backend/routers/chat.py +++ b/backend/routers/chat.py @@ -20,6 +20,7 @@ ResponseMessage, MessageConversation, FileChat, + UpdateChatSessionTitleRequest, ) from models.conversation import Conversation from routers.sync import retrieve_file_paths, decode_files_to_wav @@ -50,7 +51,12 @@ def filter_messages(messages, app_id): return collected -def acquire_chat_session(uid: str, plugin_id: Optional[str] = None): +def acquire_chat_session(uid: str, plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None): + if chat_session_id: + chat_session = chat_db.get_chat_session_by_id(uid, chat_session_id) + if chat_session: + return chat_session + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) if chat_session is None: cs = ChatSession(id=str(uuid.uuid4()), created_at=datetime.now(timezone.utc), plugin_id=plugin_id) @@ -60,7 +66,7 @@ def acquire_chat_session(uid: str, plugin_id: Optional[str] = None): @router.post('/v2/messages', tags=['chat'], response_model=ResponseMessage) def send_message( - data: SendMessageRequest, plugin_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid) + data: SendMessageRequest, plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid) ): print('send_message', data.text, plugin_id, uid) @@ -68,8 +74,12 @@ def send_message( plugin_id = None # get chat session - chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) - chat_session = ChatSession(**chat_session) if chat_session else None + if chat_session_id: + chat_session = chat_db.get_chat_session_by_id(uid, chat_session_id) + chat_session = ChatSession(**chat_session) if chat_session else None + else: + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) + chat_session = ChatSession(**chat_session) if chat_session else None message = Message( id=str(uuid.uuid4()), @@ -182,13 +192,14 @@ def report_message(message_id: str, uid: str = Depends(auth.get_current_user_uid @router.delete('/v2/messages', tags=['chat'], response_model=Message) -def clear_chat_messages(app_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): +def clear_chat_messages(app_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): if app_id in ['null', '']: app_id = None # get current chat session - chat_session = chat_db.get_chat_session(uid, app_id=app_id) - chat_session_id = chat_session['id'] if chat_session else None + if not chat_session_id: + chat_session = chat_db.get_chat_session(uid, app_id=app_id) + chat_session_id = chat_session['id'] if chat_session else None err = chat_db.clear_chat(uid, app_id=app_id, chat_session_id=chat_session_id) if err: @@ -205,11 +216,11 @@ def clear_chat_messages(app_id: Optional[str] = None, uid: str = Depends(auth.ge return initial_message_util(uid, app_id) -def initial_message_util(uid: str, app_id: Optional[str] = None): +def initial_message_util(uid: str, app_id: Optional[str] = None, chat_session_id: Optional[str] = None): print('initial_message_util', app_id) # init chat session - chat_session = acquire_chat_session(uid, plugin_id=app_id) + chat_session = acquire_chat_session(uid, plugin_id=app_id, chat_session_id=chat_session_id) prev_messages = list(reversed(chat_db.get_messages(uid, limit=5, app_id=app_id))) print('initial_message_util returned', len(prev_messages), 'prev messages for', app_id) @@ -246,24 +257,25 @@ def initial_message_util(uid: str, app_id: Optional[str] = None): @router.post('/v2/initial-message', tags=['chat'], response_model=Message) -def create_initial_message(app_id: Optional[str], uid: str = Depends(auth.get_current_user_uid)): - return initial_message_util(uid, app_id) +def create_initial_message(app_id: Optional[str], chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + return initial_message_util(uid, app_id, chat_session_id) @router.get('/v2/messages', response_model=List[Message], tags=['chat']) -def get_messages(plugin_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): +def get_messages(plugin_id: Optional[str] = None, chat_session_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): if plugin_id in ['null', '']: plugin_id = None - chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) - chat_session_id = chat_session['id'] if chat_session else None + if not chat_session_id: + chat_session = chat_db.get_chat_session(uid, app_id=plugin_id) + chat_session_id = chat_session['id'] if chat_session else None messages = chat_db.get_messages( uid, limit=100, include_conversations=True, app_id=plugin_id, chat_session_id=chat_session_id ) print('get_messages', len(messages), plugin_id) if not messages: - return [initial_message_util(uid, plugin_id)] + return [initial_message_util(uid, plugin_id, chat_session_id)] return messages @@ -527,3 +539,55 @@ async def transcribe_voice_message(files: List[UploadFile] = File(...), uid: str @router.post('/v1/initial-message', tags=['chat'], response_model=Message) def create_initial_message(plugin_id: Optional[str], uid: str = Depends(auth.get_current_user_uid)): return initial_message_util(uid, plugin_id) + + +@router.get('/v2/chat-sessions', response_model=List[ChatSession], tags=['chat']) +def get_chat_sessions(app_id: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + """Get all chat sessions for a user and app""" + if app_id in ['null', '']: + app_id = None + sessions = chat_db.get_chat_sessions(uid, app_id=app_id) + return [ChatSession(**session) for session in sessions] + + +@router.post('/v2/chat-sessions', response_model=ChatSession, tags=['chat']) +def create_chat_session(app_id: Optional[str] = None, title: Optional[str] = None, uid: str = Depends(auth.get_current_user_uid)): + """Create a new chat session""" + if app_id in ['null', '']: + app_id = None + session = chat_db.create_new_chat_session(uid, app_id, title) + return ChatSession(**session) + + +@router.get('/v2/chat-sessions/{session_id}', response_model=ChatSession, tags=['chat']) +def get_chat_session_by_id(session_id: str, uid: str = Depends(auth.get_current_user_uid)): + """Get a specific chat session by ID""" + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + return ChatSession(**session) + + +@router.put('/v2/chat-sessions/{session_id}/title', response_model=dict, tags=['chat']) +def update_chat_session_title(session_id: str, request: UpdateChatSessionTitleRequest, uid: str = Depends(auth.get_current_user_uid)): + """Update the title of a chat session""" + # Verify session exists + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + + chat_db.update_chat_session_title(uid, session_id, request.title) + return {'message': 'Title updated successfully'} + + +@router.delete('/v2/chat-sessions/{session_id}', response_model=dict, tags=['chat']) +def delete_chat_session(session_id: str, uid: str = Depends(auth.get_current_user_uid)): + """Delete a chat session""" + # Verify session exists + session = chat_db.get_chat_session_by_id(uid, session_id) + if session is None: + raise HTTPException(status_code=404, detail='Chat session not found') + + # Delete the session + chat_db.delete_chat_session(uid, session_id) + return {'message': 'Chat session deleted successfully'} From bd5fbc52c854d5a897ac1ced858abfc9fd73e80d Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 17 Jul 2025 12:47:24 +0530 Subject: [PATCH 3/9] chat session handling on the app side --- app/lib/backend/http/api/messages.dart | 129 ++++++++- app/lib/backend/schema/chat_session.dart | 74 +++++ .../desktop/pages/chat/desktop_chat_page.dart | 268 ++++++++++++++++-- app/lib/main.dart | 2 + app/lib/pages/chat/page.dart | 2 +- app/lib/providers/chat_session_provider.dart | 120 ++++++++ app/lib/providers/message_provider.dart | 82 +++++- 7 files changed, 637 insertions(+), 40 deletions(-) create mode 100644 app/lib/backend/schema/chat_session.dart create mode 100644 app/lib/providers/chat_session_provider.dart diff --git a/app/lib/backend/http/api/messages.dart b/app/lib/backend/http/api/messages.dart index 2e01d196269..a7b20d822f5 100644 --- a/app/lib/backend/http/api/messages.dart +++ b/app/lib/backend/http/api/messages.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:omi/backend/http/shared.dart'; import 'package:omi/backend/schema/message.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/env/env.dart'; import 'package:omi/utils/logger.dart'; import 'package:omi/utils/other/string_utils.dart'; @@ -12,12 +13,18 @@ import 'package:path/path.dart'; Future> getMessagesServer({ String? pluginId, + String? chatSessionId, bool dropdownSelected = false, }) async { if (pluginId == 'no_selected') pluginId = null; - // TODO: Add pagination + + var url = '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}&dropdown_selected=$dropdownSelected'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + var response = await makeApiCall( - url: '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}&dropdown_selected=$dropdownSelected', + url: url, headers: {}, method: 'GET', body: '', @@ -36,10 +43,16 @@ Future> getMessagesServer({ return []; } -Future> clearChatServer({String? pluginId}) async { +Future> clearChatServer({String? pluginId, String? chatSessionId}) async { if (pluginId == 'no_selected') pluginId = null; + + var url = '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + var response = await makeApiCall( - url: '${Env.apiBaseUrl}v2/messages?plugin_id=${pluginId ?? ''}', + url: url, headers: {}, method: 'DELETE', body: '', @@ -76,11 +89,16 @@ ServerMessageChunk? parseMessageChunk(String line, String messageId) { return null; } -Stream sendMessageStreamServer(String text, {String? appId, List? filesId}) async* { +Stream sendMessageStreamServer(String text, {String? appId, String? chatSessionId, List? filesId}) async* { var url = '${Env.apiBaseUrl}v2/messages?plugin_id=$appId'; if (appId == null || appId.isEmpty || appId == 'null' || appId == 'no_selected') { url = '${Env.apiBaseUrl}v2/messages'; } + if (chatSessionId != null) { + // Check if URL already has query parameters + var separator = url.contains('?') ? '&' : '?'; + url += '${separator}chat_session_id=$chatSessionId'; + } try { final request = await HttpClient().postUrl(Uri.parse(url)); @@ -284,3 +302,104 @@ Future transcribeVoiceMessage(File audioFile) async { throw Exception('Error transcribing voice message: $e'); } } + + +Future> getChatSessions({String? appId}) async { + if (appId == 'no_selected') appId = null; + + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions?app_id=${appId ?? ''}', + headers: {}, + method: 'GET', + body: '', + ); + + if (response == null) return []; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + var decodedBody = jsonDecode(body) as List; + return decodedBody.map((session) => ChatSession.fromJson(session)).toList(); + } + return []; +} + +Future createChatSession({String? appId, String? title}) async { + if (appId == 'no_selected') appId = null; + + var url = '${Env.apiBaseUrl}v2/chat-sessions?app_id=${appId ?? ''}'; + if (title != null) { + url += '&title=${Uri.encodeComponent(title)}'; + } + + var response = await makeApiCall( + url: url, + headers: {}, + method: 'POST', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + return ChatSession.fromJson(jsonDecode(body)); + } + return null; +} + +Future getChatSessionById(String sessionId) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId', + headers: {}, + method: 'GET', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + var body = utf8.decode(response.bodyBytes); + return ChatSession.fromJson(jsonDecode(body)); + } + return null; +} + +Future updateChatSessionTitle(String sessionId, String title) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId/title', + headers: {}, + method: 'PUT', + body: jsonEncode({'title': title}), + ); + + return response?.statusCode == 200; +} + +Future deleteChatSession(String sessionId) async { + var response = await makeApiCall( + url: '${Env.apiBaseUrl}v2/chat-sessions/$sessionId', + headers: {}, + method: 'DELETE', + body: '', + ); + + return response?.statusCode == 200; +} + +Future createInitialMessage({String? appId, String? chatSessionId}) async { + var url = '${Env.apiBaseUrl}v2/initial-message?app_id=${appId ?? ''}'; + if (chatSessionId != null) { + url += '&chat_session_id=$chatSessionId'; + } + + var response = await makeApiCall( + url: url, + headers: {}, + method: 'POST', + body: '', + ); + + if (response == null) return null; + if (response.statusCode == 200) { + return ServerMessage.fromJson(jsonDecode(response.body)); + } + return null; +} diff --git a/app/lib/backend/schema/chat_session.dart b/app/lib/backend/schema/chat_session.dart new file mode 100644 index 00000000000..d2f66d22d0a --- /dev/null +++ b/app/lib/backend/schema/chat_session.dart @@ -0,0 +1,74 @@ +class ChatSession { + final String id; + final List messageIds; + final List fileIds; + final String? appId; + final String? pluginId; + final DateTime createdAt; + final String? title; + + ChatSession({ + required this.id, + required this.messageIds, + required this.fileIds, + this.appId, + this.pluginId, + required this.createdAt, + this.title, + }); + + factory ChatSession.fromJson(Map json) { + return ChatSession( + id: json['id'] as String, + messageIds: List.from(json['message_ids'] ?? []), + fileIds: List.from(json['file_ids'] ?? []), + appId: json['app_id'] as String?, + pluginId: json['plugin_id'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + title: json['title'] as String?, + ); + } + + Map toJson() { + return { + 'id': id, + 'message_ids': messageIds, + 'file_ids': fileIds, + 'app_id': appId, + 'plugin_id': pluginId, + 'created_at': createdAt.toIso8601String(), + 'title': title, + }; + } + + ChatSession copyWith({ + String? id, + List? messageIds, + List? fileIds, + String? appId, + String? pluginId, + DateTime? createdAt, + String? title, + }) { + return ChatSession( + id: id ?? this.id, + messageIds: messageIds ?? this.messageIds, + fileIds: fileIds ?? this.fileIds, + appId: appId ?? this.appId, + pluginId: pluginId ?? this.pluginId, + createdAt: createdAt ?? this.createdAt, + title: title ?? this.title, + ); + } + + String get displayTitle { + if (title == null || title!.isEmpty) { + return 'New Chat'; + } + // If title starts with "New Chat", show "New Chat" instead + if (title!.startsWith('New Chat')) { + return 'New Chat'; + } + return title!; + } +} \ No newline at end of file diff --git a/app/lib/desktop/pages/chat/desktop_chat_page.dart b/app/lib/desktop/pages/chat/desktop_chat_page.dart index 0e6d431871a..059c82bd64f 100644 --- a/app/lib/desktop/pages/chat/desktop_chat_page.dart +++ b/app/lib/desktop/pages/chat/desktop_chat_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:omi/backend/http/api/messages.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/gen/assets.gen.dart'; @@ -17,6 +18,7 @@ import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/ui/atoms/omi_typing_indicator.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/other/temp.dart'; @@ -130,8 +132,13 @@ class DesktopChatPageState extends State with AutomaticKeepAliv SchedulerBinding.instance.addPostFrameCallback((_) async { var provider = context.read(); + var sessionProvider = context.read(); + + // Load sessions first + await sessionProvider.loadSessions(); + if (provider.messages.isEmpty) { - provider.refreshMessages(); + provider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); } _fadeController.forward(); @@ -156,8 +163,8 @@ class DesktopChatPageState extends State with AutomaticKeepAliv Widget build(BuildContext context) { super.build(context); - return Consumer3( - builder: (context, provider, connectivityProvider, appProvider, child) { + return Consumer4( + builder: (context, provider, connectivityProvider, appProvider, sessionProvider, child) { return Container( decoration: BoxDecoration( gradient: LinearGradient( @@ -172,33 +179,58 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ), child: ClipRRect( borderRadius: BorderRadius.circular(20), - child: Stack( + child: Row( children: [ - // Animated background pattern - _buildAnimatedBackground(), - - // Main content with glassmorphism + // Sessions sidebar Container( + width: 300, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.02), - borderRadius: BorderRadius.circular(20), + color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.6), + border: Border( + right: BorderSide( + color: ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), + width: 1, + ), + ), ), - child: Column( + child: _buildSessionsSidebar(sessionProvider, appProvider), + ), + + // Main chat area + Expanded( + child: Stack( children: [ - _buildModernHeader(appProvider), - if (provider.isLoadingMessages) _buildLoadingBar(), - Expanded( - child: _animationsInitialized - ? FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: _buildChatContent(provider, connectivityProvider), - ), - ) - : _buildChatContent(provider, connectivityProvider), + // Animated background pattern + _buildAnimatedBackground(), + + // Main content with glassmorphism + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.02), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + ), + child: Column( + children: [ + _buildModernHeader(appProvider), + if (provider.isLoadingMessages) _buildLoadingBar(), + Expanded( + child: _animationsInitialized + ? FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: _buildChatContent(provider, connectivityProvider), + ), + ) + : _buildChatContent(provider, connectivityProvider), + ), + _buildFloatingInputArea(provider, connectivityProvider), + ], + ), ), - _buildFloatingInputArea(provider, connectivityProvider), ], ), ), @@ -1415,11 +1447,12 @@ class DesktopChatPageState extends State with AutomaticKeepAliv void _sendMessageUtil(String text) { var provider = context.read(); + var sessionProvider = context.read(); provider.setSendingMessage(true); provider.addMessageLocally(text); scrollToBottom(); textController.clear(); - provider.sendMessageStreamToServer(text); + provider.sendMessageStreamToServer(text, chatSessionId: sessionProvider.currentSessionId, context: context); provider.clearSelectedFiles(); provider.setSendingMessage(false); } @@ -1671,7 +1704,12 @@ class DesktopChatPageState extends State with AutomaticKeepAliv appProvider.setSelectedChatAppId(app?.id); final messageProvider = context.read(); - await messageProvider.refreshMessages(dropdownSelected: true); + final sessionProvider = context.read(); + + // Refresh sessions for the new app + await sessionProvider.refreshOnAppChange(); + + await messageProvider.refreshMessages(dropdownSelected: true, chatSessionId: sessionProvider.currentSessionId); if (messageProvider.messages.isEmpty) { messageProvider.sendInitialAppMessage(app); @@ -1699,7 +1737,8 @@ class DesktopChatPageState extends State with AutomaticKeepAliv context, () => Navigator.of(context).pop(), () { - context.read().clearChat(); + final sessionProvider = context.read(); + context.read().clearChat(chatSessionId: sessionProvider.currentSessionId); Navigator.of(context).pop(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -1716,4 +1755,179 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ), ); } + + Widget _buildSessionsSidebar(ChatSessionProvider sessionProvider, AppProvider appProvider) { + return Column( + children: [ + // Header with new session button + Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Text( + 'Chat Sessions', + style: TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () async { + await sessionProvider.createNewSession(); + // Refresh messages for the new session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + scrollToBottom(); + }, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withOpacity(0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.add, + color: ResponsiveHelper.purplePrimary, + size: 16, + ), + ), + ), + ), + ], + ), + ), + + // Sessions list + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(ResponsiveHelper.purplePrimary), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: sessionProvider.sessions.length, + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isActive = session.id == sessionProvider.currentSessionId; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleSessionSwitch(sessionProvider, session), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isActive + ? ResponsiveHelper.purplePrimary.withOpacity(0.15) + : ResponsiveHelper.backgroundTertiary.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive + ? ResponsiveHelper.purplePrimary.withOpacity(0.3) + : Colors.transparent, + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + color: ResponsiveHelper.textSecondary, + size: 16, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.displayTitle, + style: TextStyle( + color: isActive + ? ResponsiveHelper.purplePrimary + : ResponsiveHelper.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + _formatSessionTime(session.createdAt), + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 11, + ), + ), + ], + ), + ), + if (sessionProvider.sessions.length > 1) ...[ + const SizedBox(width: 8), + Material( + color: Colors.transparent, + child: InkWell( + onTap: () => sessionProvider.deleteSession(session), + borderRadius: BorderRadius.circular(4), + child: Container( + padding: const EdgeInsets.all(4), + child: const Icon( + Icons.delete_outline, + color: ResponsiveHelper.textTertiary, + size: 14, + ), + ), + ), + ), + ], + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + String _formatSessionTime(DateTime time) { + final now = DateTime.now(); + final difference = now.difference(time); + + if (difference.inDays > 0) { + return '${difference.inDays}d ago'; + } else if (difference.inHours > 0) { + return '${difference.inHours}h ago'; + } else if (difference.inMinutes > 0) { + return '${difference.inMinutes}m ago'; + } else { + return 'Just now'; + } + } + + void _handleSessionSwitch(ChatSessionProvider sessionProvider, ChatSession session) async { + if (sessionProvider.currentSessionId == session.id) return; + + await sessionProvider.switchToSession(session); + + // Refresh messages for the new session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: session.id); + + scrollToBottom(); + } } diff --git a/app/lib/main.dart b/app/lib/main.dart index dd0b72143f7..0b40f6563d4 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -28,6 +28,7 @@ import 'package:omi/pages/persona/persona_provider.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/providers/auth_provider.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/developer_mode_provider.dart'; import 'package:omi/providers/mcp_provider.dart'; @@ -242,6 +243,7 @@ class _MyAppState extends State with WidgetsBindingObserver { ChangeNotifierProvider(create: (context) => PersonaProvider()), ChangeNotifierProvider(create: (context) => MemoriesProvider()), ChangeNotifierProvider(create: (context) => UserProvider()), + ChangeNotifierProvider(create: (context) => ChatSessionProvider()), ], builder: (context, child) { return WithForegroundTask( diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 2e3acbd548e..872a8fddc4f 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -609,7 +609,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { provider.addMessageLocally(text); scrollToBottom(); textController.clear(); - provider.sendMessageStreamToServer(text); + provider.sendMessageStreamToServer(text, context: context); provider.clearSelectedFiles(); provider.setSendingMessage(false); } diff --git a/app/lib/providers/chat_session_provider.dart b/app/lib/providers/chat_session_provider.dart new file mode 100644 index 00000000000..3136878c075 --- /dev/null +++ b/app/lib/providers/chat_session_provider.dart @@ -0,0 +1,120 @@ +import 'package:flutter/foundation.dart'; +import 'package:omi/backend/http/api/messages.dart'; +import 'package:omi/backend/schema/chat_session.dart'; +import 'package:omi/providers/app_provider.dart'; + +class ChatSessionProvider extends ChangeNotifier { + AppProvider? appProvider; + List sessions = []; + ChatSession? currentSession; + bool isLoadingSessions = false; + + void updateAppProvider(AppProvider p) { + appProvider = p; + } + + String? get currentSessionId => currentSession?.id; + String? get currentAppId => appProvider?.selectedChatAppId; + + Future loadSessions() async { + isLoadingSessions = true; + notifyListeners(); + + try { + final appId = appProvider?.selectedChatAppId; + sessions = await getChatSessions(appId: appId); + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + // If no sessions exist, create a new one + if (sessions.isEmpty) { + await createNewSession(); + } + } catch (e) { + debugPrint('Error loading sessions: $e'); + } finally { + isLoadingSessions = false; + notifyListeners(); + } + } + + Future createNewSession({String? title}) async { + try { + final appId = appProvider?.selectedChatAppId; + final newSession = await createChatSession(appId: appId, title: title); + + if (newSession != null) { + sessions.insert(0, newSession); + currentSession = newSession; + notifyListeners(); + } + } catch (e) { + debugPrint('Error creating new session: $e'); + } + } + + Future switchToSession(ChatSession session) async { + if (currentSession?.id != session.id) { + currentSession = session; + notifyListeners(); + } + } + + Future deleteSession(ChatSession session) async { + try { + final success = await deleteChatSession(session.id); + if (success) { + sessions.removeWhere((s) => s.id == session.id); + + // If we deleted the current session, switch to another one + if (currentSession?.id == session.id) { + if (sessions.isNotEmpty) { + currentSession = sessions.first; + } else { + await createNewSession(); + } + } + + notifyListeners(); + } + } catch (e) { + debugPrint('Error deleting session: $e'); + } + } + + Future updateSessionTitle(ChatSession session, String title) async { + try { + final success = await updateChatSessionTitle(session.id, title); + if (success) { + final index = sessions.indexWhere((s) => s.id == session.id); + if (index != -1) { + sessions[index] = session.copyWith(title: title); + if (currentSession?.id == session.id) { + currentSession = sessions[index]; + } + notifyListeners(); + } + } + } catch (e) { + debugPrint('Error updating session title: $e'); + } + } + + Future refreshOnAppChange() async { + // Clear current state + sessions.clear(); + currentSession = null; + + // Load sessions for the new app + await loadSessions(); + } + + void clear() { + sessions.clear(); + currentSession = null; + notifyListeners(); + } +} \ No newline at end of file diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index 6ea0bca610e..b14d100c65d 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -17,6 +17,9 @@ import 'package:omi/utils/file.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/platform/platform_service.dart'; import 'package:uuid/uuid.dart'; +import 'package:flutter/material.dart' as flutter; +import 'package:omi/providers/chat_session_provider.dart'; +import 'package:provider/provider.dart'; class MessageProvider extends ChangeNotifier { AppProvider? appProvider; @@ -265,12 +268,12 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future refreshMessages({bool dropdownSelected = false}) async { + Future refreshMessages({bool dropdownSelected = false, String? chatSessionId}) async { setLoadingMessages(true); if (SharedPreferencesUtil().cachedMessages.isNotEmpty) { setHasCachedMessages(true); } - messages = await getMessagesFromServer(dropdownSelected: dropdownSelected); + messages = await getMessagesFromServer(dropdownSelected: dropdownSelected, chatSessionId: chatSessionId); if (messages.isEmpty) { messages = SharedPreferencesUtil().cachedMessages; } else { @@ -289,7 +292,7 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future> getMessagesFromServer({bool dropdownSelected = false}) async { + Future> getMessagesFromServer({bool dropdownSelected = false, String? chatSessionId}) async { if (!hasCachedMessages) { firstTimeLoadingText = 'Reading your memories...'; notifyListeners(); @@ -297,6 +300,7 @@ class MessageProvider extends ChangeNotifier { setLoadingMessages(true); var mes = await getMessagesServer( pluginId: appProvider?.selectedChatAppId, + chatSessionId: chatSessionId, dropdownSelected: dropdownSelected, ); if (!hasCachedMessages) { @@ -315,9 +319,9 @@ class MessageProvider extends ChangeNotifier { notifyListeners(); } - Future clearChat() async { + Future clearChat({String? chatSessionId}) async { setClearingChat(true); - var mes = await clearChatServer(pluginId: appProvider?.selectedChatAppId); + var mes = await clearChatServer(pluginId: appProvider?.selectedChatAppId, chatSessionId: chatSessionId); messages = mes; setClearingChat(false); notifyListeners(); @@ -431,7 +435,7 @@ class MessageProvider extends ChangeNotifier { setShowTypingIndicator(false); } - Future sendMessageStreamToServer(String text) async { + Future sendMessageStreamToServer(String text, {String? chatSessionId, flutter.BuildContext? context}) async { setShowTypingIndicator(true); var currentAppId = appProvider?.selectedChatAppId; if (currentAppId == 'no_selected') { @@ -458,8 +462,9 @@ class MessageProvider extends ChangeNotifier { List fileIds = uploadedFiles.map((e) => e.id).toList(); clearSelectedFiles(); clearUploadedFiles(); + bool messageSuccessful = false; try { - await for (var chunk in sendMessageStreamServer(text, appId: currentAppId, filesId: fileIds)) { + await for (var chunk in sendMessageStreamServer(text, appId: currentAppId, chatSessionId: chatSessionId, filesId: fileIds)) { if (chunk.type == MessageChunkType.think) { message.thinkings.add(chunk.text); notifyListeners(); @@ -475,6 +480,7 @@ class MessageProvider extends ChangeNotifier { if (chunk.type == MessageChunkType.done) { message = chunk.message!; messages[0] = message; + messageSuccessful = true; notifyListeners(); continue; } @@ -491,6 +497,11 @@ class MessageProvider extends ChangeNotifier { } setShowTypingIndicator(false); + + // Update session title if this is the first user message and we have a context + if (messageSuccessful && chatSessionId != null && context != null) { + await _updateSessionTitleIfNeeded(chatSessionId, text, context); + } } Future sendInitialAppMessage(App? app) async { @@ -504,4 +515,61 @@ class MessageProvider extends ChangeNotifier { App? messageSenderApp(String? appId) { return appProvider?.apps.firstWhereOrNull((p) => p.id == appId); } + + String _generateTitleFromMessage(String message) { + // Remove extra whitespace and split into words + List words = message.trim().split(RegExp(r'\s+')); + + // Filter out very short words and common words + List meaningfulWords = words + .where((word) => word.length > 2) + .where((word) => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'].contains(word.toLowerCase())) + .toList(); + + // Take first 3-5 meaningful words + List titleWords = meaningfulWords.take(4).toList(); + + // If we don't have enough meaningful words, use the first few words + if (titleWords.length < 2) { + titleWords = words.take(4).toList(); + } + + // Capitalize first letter of each word + titleWords = titleWords.map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).toList(); + + String title = titleWords.join(' '); + + // Limit length to 50 characters + if (title.length > 50) { + title = title.substring(0, 47) + '...'; + } + + return title; + } + + Future _updateSessionTitleIfNeeded(String chatSessionId, String messageText, flutter.BuildContext context) async { + try { + // Check if this is the first user message in the session + List userMessages = messages.where((m) => m.sender == MessageSender.human).toList(); + + if (userMessages.length == 1) { + // This is the first user message, generate and update title + String title = _generateTitleFromMessage(messageText); + + // Update title in backend + bool success = await updateChatSessionTitle(chatSessionId, title); + + if (success) { + // Update title in session provider + final sessionProvider = context.read(); + final currentSession = sessionProvider.currentSession; + if (currentSession != null && currentSession.id == chatSessionId) { + await sessionProvider.updateSessionTitle(currentSession, title); + } + } + } + } catch (e) { + debugPrint('Error updating session title: $e'); + } + } } From 6fd1feea8060a37b669a2d39fab9153a9c51fe67 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 18 Jul 2025 18:27:55 +0530 Subject: [PATCH 4/9] cache already fetched sessions --- app/lib/backend/preferences.dart | 49 +++++++++++++++ app/lib/providers/chat_session_provider.dart | 65 +++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index aafaac25cf1..46aa7e09ba2 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -6,6 +6,7 @@ import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; import 'package:omi/backend/schema/person.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/services/wals.dart'; import 'package:omi/utils/platform/platform_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -148,6 +149,12 @@ class SharedPreferencesUtil { set showInstallAppConfirmation(bool value) => saveBool('showInstallAppConfirmation', value); + //-------------------------------- Sidebar ----------------------------------// + + bool get sidebarCollapsed => getBool('sidebarCollapsed') ?? false; + + set sidebarCollapsed(bool value) => saveBool('sidebarCollapsed', value); + bool get showFirmwareUpdateDialog => getBool('v2/showFirmwareUpdateDialog') ?? true; set showFirmwareUpdateDialog(bool value) => saveBool('v2/showFirmwareUpdateDialog', value); @@ -276,6 +283,44 @@ class SharedPreferencesUtil { saveStringList('cachedMessages', messages); } + List get cachedSessions { + final List sessions = getStringList('cachedSessions') ?? []; + return sessions.map((e) => ChatSession.fromJson(jsonDecode(e))).toList(); + } + + set cachedSessions(List value) { + final List sessions = value.map((e) => jsonEncode(e.toJson())).toList(); + saveStringList('cachedSessions', sessions); + } + + // Cache sessions by app ID for better performance + List getCachedSessionsForApp(String? appId) { + final key = 'cachedSessions_${appId ?? 'default'}'; + final List sessions = getStringList(key) ?? []; + return sessions.map((e) => ChatSession.fromJson(jsonDecode(e))).toList(); + } + + void setCachedSessionsForApp(String? appId, List sessions) { + final key = 'cachedSessions_${appId ?? 'default'}'; + final List sessionStrings = sessions.map((e) => jsonEncode(e.toJson())).toList(); + saveStringList(key, sessionStrings); + } + + void clearCachedSessionsForApp(String? appId) { + final key = 'cachedSessions_${appId ?? 'default'}'; + _preferences?.remove(key); + } + + Future clearAllCachedSessions() async { + // Get all keys to find session cache keys + final allKeys = _preferences?.getKeys() ?? {}; + for (final key in allKeys) { + if (key.startsWith('cachedSessions_')) { + await _preferences?.remove(key); + } + } + } + List get cachedPeople { final List people = getStringList('cachedPeople') ?? []; return people.map((e) => Person.fromJson(jsonDecode(e))).toList(); @@ -448,7 +493,11 @@ class SharedPreferencesUtil { await remove('cachedConversations'); await remove('cachedMessages'); await remove('cachedPeople'); + await remove('cachedSessions'); await remove('modifiedConversationDetails'); + + // Clear app-specific cached sessions + await clearAllCachedSessions(); // Remove app related data await remove('selectedChatAppId2'); diff --git a/app/lib/providers/chat_session_provider.dart b/app/lib/providers/chat_session_provider.dart index 3136878c075..237e2a95e7c 100644 --- a/app/lib/providers/chat_session_provider.dart +++ b/app/lib/providers/chat_session_provider.dart @@ -2,12 +2,14 @@ import 'package:flutter/foundation.dart'; import 'package:omi/backend/http/api/messages.dart'; import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/backend/preferences.dart'; class ChatSessionProvider extends ChangeNotifier { AppProvider? appProvider; List sessions = []; ChatSession? currentSession; bool isLoadingSessions = false; + bool hasCachedSessions = false; void updateAppProvider(AppProvider p) { appProvider = p; @@ -22,7 +24,32 @@ class ChatSessionProvider extends ChangeNotifier { try { final appId = appProvider?.selectedChatAppId; - sessions = await getChatSessions(appId: appId); + + // First, try to load cached sessions + final cachedSessions = SharedPreferencesUtil().getCachedSessionsForApp(appId); + if (cachedSessions.isNotEmpty) { + sessions = cachedSessions; + hasCachedSessions = true; + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + // Notify listeners early with cached data + isLoadingSessions = false; + notifyListeners(); + } + + // Then load from server and update cache + List serverSessions = await getChatSessions(appId: appId); + sessions = serverSessions; + + // Update cache with fresh data + if (sessions.isNotEmpty) { + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + hasCachedSessions = true; + } // If no current session and we have sessions, set the first one as current if (currentSession == null && sessions.isNotEmpty) { @@ -35,6 +62,10 @@ class ChatSessionProvider extends ChangeNotifier { } } catch (e) { debugPrint('Error loading sessions: $e'); + // If we have cached sessions, use them even if server fails + if (hasCachedSessions && sessions.isNotEmpty) { + debugPrint('Using cached sessions due to server error'); + } } finally { isLoadingSessions = false; notifyListeners(); @@ -49,6 +80,11 @@ class ChatSessionProvider extends ChangeNotifier { if (newSession != null) { sessions.insert(0, newSession); currentSession = newSession; + + // Update cache with new session + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + hasCachedSessions = true; + notifyListeners(); } } catch (e) { @@ -78,6 +114,10 @@ class ChatSessionProvider extends ChangeNotifier { } } + // Update cache + final appId = appProvider?.selectedChatAppId; + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + notifyListeners(); } } catch (e) { @@ -95,6 +135,11 @@ class ChatSessionProvider extends ChangeNotifier { if (currentSession?.id == session.id) { currentSession = sessions[index]; } + + // Update cache + final appId = appProvider?.selectedChatAppId; + SharedPreferencesUtil().setCachedSessionsForApp(appId, sessions); + notifyListeners(); } } @@ -107,14 +152,32 @@ class ChatSessionProvider extends ChangeNotifier { // Clear current state sessions.clear(); currentSession = null; + hasCachedSessions = false; // Load sessions for the new app await loadSessions(); } + void setSessionsFromCache() { + final appId = appProvider?.selectedChatAppId; + final cachedSessions = SharedPreferencesUtil().getCachedSessionsForApp(appId); + if (cachedSessions.isNotEmpty) { + sessions = cachedSessions; + hasCachedSessions = true; + + // If no current session and we have sessions, set the first one as current + if (currentSession == null && sessions.isNotEmpty) { + currentSession = sessions.first; + } + + notifyListeners(); + } + } + void clear() { sessions.clear(); currentSession = null; + hasCachedSessions = false; notifyListeners(); } } \ No newline at end of file From 1368fe11d7ba49925d53b071d5f227d1e2013085 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Fri, 18 Jul 2025 18:28:44 +0530 Subject: [PATCH 5/9] make sidebar collapsible --- .../desktop/pages/chat/desktop_chat_page.dart | 55 +-- app/lib/desktop/pages/desktop_home_page.dart | 328 ++++++++++++------ 2 files changed, 247 insertions(+), 136 deletions(-) diff --git a/app/lib/desktop/pages/chat/desktop_chat_page.dart b/app/lib/desktop/pages/chat/desktop_chat_page.dart index 059c82bd64f..31e1c25c9f4 100644 --- a/app/lib/desktop/pages/chat/desktop_chat_page.dart +++ b/app/lib/desktop/pages/chat/desktop_chat_page.dart @@ -134,7 +134,10 @@ class DesktopChatPageState extends State with AutomaticKeepAliv var provider = context.read(); var sessionProvider = context.read(); - // Load sessions first + // Load sessions from cache first for instant UI + sessionProvider.setSessionsFromCache(); + + // Then load sessions from server await sessionProvider.loadSessions(); if (provider.messages.isEmpty) { @@ -198,36 +201,36 @@ class DesktopChatPageState extends State with AutomaticKeepAliv // Main chat area Expanded( - child: Stack( - children: [ - // Animated background pattern - _buildAnimatedBackground(), + child: Stack( + children: [ + // Animated background pattern + _buildAnimatedBackground(), - // Main content with glassmorphism - Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.02), + // Main content with glassmorphism + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.02), borderRadius: const BorderRadius.only( topRight: Radius.circular(20), bottomRight: Radius.circular(20), ), - ), - child: Column( - children: [ - _buildModernHeader(appProvider), - if (provider.isLoadingMessages) _buildLoadingBar(), - Expanded( - child: _animationsInitialized - ? FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: _buildChatContent(provider, connectivityProvider), - ), - ) - : _buildChatContent(provider, connectivityProvider), - ), - _buildFloatingInputArea(provider, connectivityProvider), + ), + child: Column( + children: [ + _buildModernHeader(appProvider), + if (provider.isLoadingMessages) _buildLoadingBar(), + Expanded( + child: _animationsInitialized + ? FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: _buildChatContent(provider, connectivityProvider), + ), + ) + : _buildChatContent(provider, connectivityProvider), + ), + _buildFloatingInputArea(provider, connectivityProvider), ], ), ), diff --git a/app/lib/desktop/pages/desktop_home_page.dart b/app/lib/desktop/pages/desktop_home_page.dart index 5e4bd89b40f..67d5c439a42 100644 --- a/app/lib/desktop/pages/desktop_home_page.dart +++ b/app/lib/desktop/pages/desktop_home_page.dart @@ -92,15 +92,15 @@ class _MacWindowButtonState extends State<_MacWindowButton> { width: 12, height: 12, decoration: BoxDecoration( - color: _isHovered ? _getButtonColor() : const Color(0xFFFFFFFF).withOpacity(0.07), + color: _isHovered ? _getButtonColor() : const Color(0xFFFFFFFF).withValues(alpha: 0.07), borderRadius: BorderRadius.circular(6), border: Border.all( - color: _isHovered ? _getButtonColor().withOpacity(0.8) : const Color(0xFFD0D0D0), + color: _isHovered ? _getButtonColor().withValues(alpha: 0.8) : const Color(0xFFD0D0D0), width: 0.5, ), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.1), + color: Colors.black.withValues(alpha: 0.1), blurRadius: 1, offset: const Offset(0, 0.5), ), @@ -111,8 +111,8 @@ class _MacWindowButtonState extends State<_MacWindowButton> { _getButtonIcon(), size: 8, color: widget.type == MacWindowButtonType.close - ? Colors.white.withOpacity(0.9) - : Colors.black.withOpacity(0.7), + ? Colors.white.withValues(alpha: 0.9) + : Colors.black.withValues(alpha: 0.7), ) : null, ), @@ -138,6 +138,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb final GlobalKey _profileCardKey = GlobalKey(); bool _isRecordingMinimized = false; + bool _isSidebarCollapsed = false; // Native overlay platform channel static const _overlayChannel = MethodChannel('overlayPlatform'); @@ -178,6 +179,9 @@ class _DesktopHomePageState extends State with WidgetsBindingOb void initState() { SharedPreferencesUtil().onboardingCompleted = true; + // Initialize sidebar collapse state + _isSidebarCollapsed = SharedPreferencesUtil().sidebarCollapsed; + // Initialize animations _sidebarAnimationController = AnimationController( duration: const Duration(milliseconds: 300), @@ -456,28 +460,31 @@ class _DesktopHomePageState extends State with WidgetsBindingOb offset: Offset(-50 * (1 - _sidebarSlideAnimation.value), 0), child: Opacity( opacity: _sidebarSlideAnimation.value, - child: Container( - width: responsive.sidebarWidth(baseWidth: 280), - decoration: BoxDecoration( - color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.85), - borderRadius: const BorderRadius.only( - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), - ), - border: const Border( - right: BorderSide( - color: ResponsiveHelper.backgroundTertiary, - width: 1, + child: ClipRect( + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + width: _isSidebarCollapsed ? 130 : responsive.sidebarWidth(baseWidth: 280), + decoration: BoxDecoration( + color: ResponsiveHelper.backgroundSecondary.withValues(alpha: 0.85), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), ), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 10, - offset: const Offset(2, 0), + border: const Border( + right: BorderSide( + color: ResponsiveHelper.backgroundTertiary, + width: 1, + ), ), - ], - ), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(2, 0), + ), + ], + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -489,7 +496,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb // Main navigation section Expanded( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20), + padding: EdgeInsets.symmetric(horizontal: _isSidebarCollapsed ? 18 : 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -596,6 +603,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ), ), ), + ), ); }, ); @@ -612,18 +620,23 @@ class _DesktopHomePageState extends State with WidgetsBindingOb margin: const EdgeInsets.symmetric(vertical: 1), child: Stack( children: [ - // Navigation item with full container - Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - MixpanelManager() - .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Actions', 'Apps'][index]); - onTap(); - }, - borderRadius: BorderRadius.circular(8), + // Navigation item with full container + Material( + color: Colors.transparent, + child: Tooltip( + message: _isSidebarCollapsed ? label : '', + child: InkWell( + onTap: () { + MixpanelManager() + .bottomNavigationTabClicked(['Conversations', 'Chat', 'Memories', 'Actions', 'Apps'][index]); + onTap(); + }, + borderRadius: BorderRadius.circular(8), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: EdgeInsets.symmetric( + horizontal: _isSidebarCollapsed ? 18 : 16, + vertical: 12, + ), decoration: BoxDecoration( color: isSelected ? ResponsiveHelper.backgroundTertiary.withOpacity(0.8) : Colors.transparent, borderRadius: BorderRadius.circular(8), @@ -635,23 +648,33 @@ class _DesktopHomePageState extends State with WidgetsBindingOb color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, size: 18, ), - const SizedBox(width: 12), - Expanded( - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, - color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + if (!_isSidebarCollapsed) ...[ + const SizedBox(width: 12), + Expanded( + child: AnimatedOpacity( + opacity: _isSidebarCollapsed ? 0.0 : 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), ), - ), + ], ], ), ), ), ), - // Selection accent line spanning full item height + ), + // Selection accent line spanning full item height if (isSelected) Positioned( right: 0, @@ -673,6 +696,8 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + + void _navigateToIndex(int index, HomeProvider homeProvider) { if (homeProvider.selectedIndex == index) return; @@ -684,6 +709,13 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + void _toggleSidebarCollapse() { + setState(() { + _isSidebarCollapsed = !_isSidebarCollapsed; + }); + SharedPreferencesUtil().sidebarCollapsed = _isSidebarCollapsed; + } + /// Navigate to create app page (index 5) void navigateToCreateApp() { final homeProvider = Provider.of(context, listen: false); @@ -824,7 +856,7 @@ class _DesktopHomePageState extends State with WidgetsBindingOb Widget _buildWindowControls() { return Container( height: 52, - padding: const EdgeInsets.fromLTRB(24, 16, 16, 0), + padding: const EdgeInsets.fromLTRB(18, 16, 16, 0), child: Row( children: [ // Close button @@ -886,6 +918,11 @@ class _DesktopHomePageState extends State with WidgetsBindingOb } }, ), + + const SizedBox(width: 16), + + // Collapse/Expand button + _buildWindowCollapseButton(), ], ), ); @@ -901,6 +938,33 @@ class _DesktopHomePageState extends State with WidgetsBindingOb ); } + Widget _buildWindowCollapseButton() { + return MouseRegion( + onEnter: (_) => setState(() {}), + onExit: (_) => setState(() {}), + child: GestureDetector( + onTap: _toggleSidebarCollapse, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: ResponsiveHelper.textSecondary.withOpacity(0.3), + width: 1, + ), + ), + child: Icon( + _isSidebarCollapsed ? FontAwesomeIcons.chevronRight : FontAwesomeIcons.chevronLeft, + color: ResponsiveHelper.textSecondary, + size: 12, + ), + ), + ), + ); + } + Widget _buildProfileCard() { final userName = SharedPreferencesUtil().givenName; final userEmail = SharedPreferencesUtil().email; @@ -923,70 +987,114 @@ class _DesktopHomePageState extends State with WidgetsBindingOb width: 1, ), ), - child: Row( - children: [ - // Profile picture - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), - width: 1.5, - ), - ), - child: Center( - child: Text( - userName.isNotEmpty ? userName[0].toUpperCase() : 'U', - style: const TextStyle( - color: ResponsiveHelper.purplePrimary, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ), - ), + child: ClipRect( + child: LayoutBuilder( + builder: (context, constraints) { + final shouldShowFullLayout = constraints.maxWidth > 160; + + return shouldShowFullLayout && !_isSidebarCollapsed + ? Row( + children: [ + // Profile picture + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Center( + child: Text( + userName.isNotEmpty ? userName[0].toUpperCase() : 'U', + style: const TextStyle( + color: ResponsiveHelper.purplePrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), - const SizedBox(width: 12), - - // User info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - userName.isNotEmpty ? userName : 'User', - style: const TextStyle( - color: ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - userEmail.isNotEmpty ? userEmail : 'No email set', - style: const TextStyle( - color: ResponsiveHelper.textTertiary, - fontSize: 11, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), + const SizedBox(width: 12), + + // User info + Expanded( + child: AnimatedOpacity( + opacity: shouldShowFullLayout && !_isSidebarCollapsed ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + userName.isNotEmpty ? userName : 'User', + style: const TextStyle( + color: ResponsiveHelper.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + userEmail.isNotEmpty ? userEmail : 'No email set', + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), - // Chevron icon - const Icon( - FontAwesomeIcons.chevronUp, - color: ResponsiveHelper.textSecondary, - size: 12, - ), - ], + // Chevron icon + AnimatedOpacity( + opacity: shouldShowFullLayout && !_isSidebarCollapsed ? 1.0 : 0.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: const Icon( + FontAwesomeIcons.chevronUp, + color: ResponsiveHelper.textSecondary, + size: 12, + ), + ), + ], + ) + : Center( + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: ResponsiveHelper.purplePrimary.withValues(alpha: 0.3), + width: 1.5, + ), + ), + child: Center( + child: Text( + userName.isNotEmpty ? userName[0].toUpperCase() : 'U', + style: const TextStyle( + color: ResponsiveHelper.purplePrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + }, + ), ), ), ), From 63a8a42909c57e4208ab05c0c2f13a7cc2d0a88e Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sat, 19 Jul 2025 21:14:08 +0530 Subject: [PATCH 6/9] generate session title on the backend --- backend/routers/chat.py | 22 +++++++++++++- backend/utils/llm/chat.py | 62 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/backend/routers/chat.py b/backend/routers/chat.py index af3f04b5351..dda88847755 100644 --- a/backend/routers/chat.py +++ b/backend/routers/chat.py @@ -31,7 +31,7 @@ transcribe_voice_message_segment, ) from utils.llm.persona import initial_persona_chat_message -from utils.llm.chat import initial_chat_message +from utils.llm.chat import initial_chat_message, generate_session_title from utils.other import endpoints as auth, storage from utils.other.chat_file import FileChatTool from utils.retrieval.graph import execute_graph_chat, execute_graph_chat_stream, execute_persona_chat_stream @@ -109,6 +109,26 @@ def send_message( chat_db.add_message(uid, message.dict()) + # Auto-generate title for new sessions based on first user message + if (chat_session and + hasattr(chat_session, 'title') and + chat_session.title and + (chat_session.title.startswith('New Chat') or not chat_session.title.strip()) and + data.text and len(data.text.strip()) > 5): + + try: + session_messages = chat_db.get_messages(uid, limit=10, chat_session_id=chat_session.id) + user_messages = [msg for msg in session_messages if msg.get('sender') == 'human'] + + if len(user_messages) <= 1: + new_title = generate_session_title(data.text) + if new_title and new_title != "New Chat": + chat_db.update_chat_session_title(uid, chat_session.id, new_title) + print(f"Auto-generated title for session {chat_session.id}: '{new_title}'") + + except Exception as e: + print(f"Failed to generate title for session {chat_session.id}: {e}") + app = get_available_app_by_id(plugin_id, uid) app = App(**app) if app else None diff --git a/backend/utils/llm/chat.py b/backend/utils/llm/chat.py index 7c7f07b2bd3..ffb41a46a10 100644 --- a/backend/utils/llm/chat.py +++ b/backend/utils/llm/chat.py @@ -44,6 +44,68 @@ def initial_chat_message(uid: str, plugin: Optional[App] = None, prev_messages_s return llm_mini.invoke(prompt).content +def generate_session_title(message_text: str) -> str: + """ + Generate a concise, meaningful title for a chat session based on the first user message. + The title should be 3-4 words maximum and capture the main topic or intent. + """ + + if not message_text or not message_text.strip(): + return "New Chat" + + clean_text = message_text.strip()[:500] + + prompt = f""" + You are an expert at creating concise, meaningful titles for conversations. + + Your task is to generate a short title (3-4 words maximum) that captures the main topic or intent of this message. + The title should be: + - Very concise (3-4 words max) + - Clear and descriptive + - Focus on the main topic, not implementation details + - Use Title Case (capitalize first letter of each word) + - Avoid generic words like "Help", "Question", "Issue" unless very specific + - Avoid quotes or special characters + + Examples of good titles: + - "Python Data Analysis" (for questions about analyzing data with Python) + - "Travel Planning Europe" (for questions about planning European travel) + - "Career Change Advice" (for questions about switching careers) + - "Recipe Recommendations" (for asking about food recipes) + - "Investment Strategy" (for financial planning questions) + - "Machine Learning Setup" (for ML environment questions) + - "React Component Design" (for React development questions) + + Message: ```{clean_text}``` + + Output only the title, nothing else. + """.replace(' ', '').strip() + + try: + response = llm_mini.invoke(prompt) + title = response.content.strip() + + title = title.replace('"', '').replace("'", "").strip() + + words = title.split() + if len(words) > 4: + title = ' '.join(words[:4]) + elif len(words) == 0: + title = "New Chat" + + title = ' '.join(word.capitalize() for word in title.split()) + + return title + + except Exception as e: + print(f"Error generating session title: {e}") + words = clean_text.split()[:3] + fallback_title = ' '.join(words).title() if words else "New Chat" + + fallback_title = fallback_title.replace('?', '').replace('!', '').strip() + return fallback_title[:50] + + # ********************************************* # ************* RETRIEVAL + CHAT ************** # ********************************************* From 5b92c359ac67c5854dab045db77f7b257a6661ce Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sat, 19 Jul 2025 21:14:40 +0530 Subject: [PATCH 7/9] desktop sidebar improvements --- .../desktop/pages/chat/desktop_chat_page.dart | 157 ++++++------------ app/lib/desktop/pages/desktop_home_page.dart | 95 ++++++----- app/lib/ui/molecules/omi_session_tile.dart | 112 +++++++++++++ 3 files changed, 212 insertions(+), 152 deletions(-) create mode 100644 app/lib/ui/molecules/omi_session_tile.dart diff --git a/app/lib/desktop/pages/chat/desktop_chat_page.dart b/app/lib/desktop/pages/chat/desktop_chat_page.dart index 31e1c25c9f4..ed88bd7d2ce 100644 --- a/app/lib/desktop/pages/chat/desktop_chat_page.dart +++ b/app/lib/desktop/pages/chat/desktop_chat_page.dart @@ -36,6 +36,8 @@ import 'package:omi/ui/atoms/omi_message_input.dart'; import 'package:omi/ui/atoms/omi_send_button.dart'; import 'package:omi/ui/atoms/omi_icon_button.dart'; import 'package:omi/ui/molecules/omi_section_header.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:omi/ui/molecules/omi_session_tile.dart'; import 'widgets/desktop_message_action_menu.dart'; @@ -1759,11 +1761,40 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ); } + void _showDeleteSessionDialog(BuildContext context, ChatSessionProvider sessionProvider, ChatSession session) async { + final confirmed = await OmiConfirmDialog.show( + context, + title: 'Delete Session?', + message: 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + confirmLabel: 'Delete', + confirmColor: ResponsiveHelper.errorColor, + ); + + if (confirmed == true) { + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + scrollToBottom(); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: ResponsiveHelper.backgroundTertiary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } + Widget _buildSessionsSidebar(ChatSessionProvider sessionProvider, AppProvider appProvider) { return Column( children: [ // Header with new session button - Container( + Padding( padding: const EdgeInsets.all(16), child: Row( children: [ @@ -1776,30 +1807,18 @@ class DesktopChatPageState extends State with AutomaticKeepAliv ), ), const Spacer(), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - await sessionProvider.createNewSession(); - // Refresh messages for the new session - final messageProvider = context.read(); - await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); - scrollToBottom(); - }, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: ResponsiveHelper.purplePrimary.withOpacity(0.15), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.add, - color: ResponsiveHelper.purplePrimary, - size: 16, - ), - ), - ), + OmiIconButton( + icon: Icons.add, + onPressed: () async { + await sessionProvider.createNewSession(); + // Refresh messages for the new session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + scrollToBottom(); + }, + style: OmiIconButtonStyle.filled, + size: 32, + iconSize: 16, ), ], ), @@ -1820,85 +1839,15 @@ class DesktopChatPageState extends State with AutomaticKeepAliv final session = sessionProvider.sessions[index]; final isActive = session.id == sessionProvider.currentSessionId; - return Container( - margin: const EdgeInsets.only(bottom: 8), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _handleSessionSwitch(sessionProvider, session), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: isActive - ? ResponsiveHelper.purplePrimary.withOpacity(0.15) - : ResponsiveHelper.backgroundTertiary.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isActive - ? ResponsiveHelper.purplePrimary.withOpacity(0.3) - : Colors.transparent, - width: 1, - ), - ), - child: Row( - children: [ - const Icon( - Icons.chat_bubble_outline_rounded, - color: ResponsiveHelper.textSecondary, - size: 16, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.displayTitle, - style: TextStyle( - color: isActive - ? ResponsiveHelper.purplePrimary - : ResponsiveHelper.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - _formatSessionTime(session.createdAt), - style: const TextStyle( - color: ResponsiveHelper.textTertiary, - fontSize: 11, - ), - ), - ], - ), - ), - if (sessionProvider.sessions.length > 1) ...[ - const SizedBox(width: 8), - Material( - color: Colors.transparent, - child: InkWell( - onTap: () => sessionProvider.deleteSession(session), - borderRadius: BorderRadius.circular(4), - child: Container( - padding: const EdgeInsets.all(4), - child: const Icon( - Icons.delete_outline, - color: ResponsiveHelper.textTertiary, - size: 14, - ), - ), - ), - ), - ], - ], - ), - ), - ), - ), + return OmiSessionTile( + title: session.displayTitle, + subtitle: _formatSessionTime(session.createdAt), + isActive: isActive, + onTap: () => _handleSessionSwitch(sessionProvider, session), + onDelete: sessionProvider.sessions.length > 1 + ? () => _showDeleteSessionDialog(context, sessionProvider, session) + : null, + showDeleteButton: sessionProvider.sessions.length > 1, ); }, ), diff --git a/app/lib/desktop/pages/desktop_home_page.dart b/app/lib/desktop/pages/desktop_home_page.dart index 67d5c439a42..8b8a1d8a822 100644 --- a/app/lib/desktop/pages/desktop_home_page.dart +++ b/app/lib/desktop/pages/desktop_home_page.dart @@ -634,42 +634,48 @@ class _DesktopHomePageState extends State with WidgetsBindingOb borderRadius: BorderRadius.circular(8), child: Container( padding: EdgeInsets.symmetric( - horizontal: _isSidebarCollapsed ? 18 : 16, + horizontal: _isSidebarCollapsed ? 0 : 16, vertical: 12, ), decoration: BoxDecoration( color: isSelected ? ResponsiveHelper.backgroundTertiary.withOpacity(0.8) : Colors.transparent, borderRadius: BorderRadius.circular(8), ), - child: Row( - children: [ - Icon( - icon, - color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, - size: 18, - ), - if (!_isSidebarCollapsed) ...[ - const SizedBox(width: 12), - Expanded( - child: AnimatedOpacity( - opacity: _isSidebarCollapsed ? 0.0 : 1.0, - duration: const Duration(milliseconds: 150), - curve: Curves.easeOut, - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, - color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + child: _isSidebarCollapsed + ? Center( + child: Icon( + icon, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + size: 18, + ), + ) + : Row( + children: [ + Icon( + icon, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + size: 18, + ), + const SizedBox(width: 12), + Expanded( + child: AnimatedOpacity( + opacity: 1.0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + color: isSelected ? ResponsiveHelper.textPrimary : ResponsiveHelper.textSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), - overflow: TextOverflow.ellipsis, - maxLines: 1, ), - ), + ], ), - ], - ], - ), ), ), ), @@ -851,7 +857,6 @@ class _DesktopHomePageState extends State with WidgetsBindingOb } } - // Legacy Flutter floating widget removed - now using native macOS overlay Widget _buildWindowControls() { return Container( @@ -939,26 +944,20 @@ class _DesktopHomePageState extends State with WidgetsBindingOb } Widget _buildWindowCollapseButton() { - return MouseRegion( - onEnter: (_) => setState(() {}), - onExit: (_) => setState(() {}), - child: GestureDetector( - onTap: _toggleSidebarCollapse, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: Colors.transparent, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: ResponsiveHelper.textSecondary.withOpacity(0.3), - width: 1, - ), - ), - child: Icon( - _isSidebarCollapsed ? FontAwesomeIcons.chevronRight : FontAwesomeIcons.chevronLeft, - color: ResponsiveHelper.textSecondary, - size: 12, + return Material( + color: Colors.transparent, + child: Tooltip( + message: _isSidebarCollapsed ? 'Expand Sidebar' : 'Collapse Sidebar', + child: InkWell( + onTap: _toggleSidebarCollapse, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.all(6), + child: Icon( + _isSidebarCollapsed ? FontAwesomeIcons.indent : FontAwesomeIcons.outdent, + color: ResponsiveHelper.textSecondary, + size: 14, + ), ), ), ), diff --git a/app/lib/ui/molecules/omi_session_tile.dart b/app/lib/ui/molecules/omi_session_tile.dart new file mode 100644 index 00000000000..97242d2f0bb --- /dev/null +++ b/app/lib/ui/molecules/omi_session_tile.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:omi/ui/atoms/omi_icon_button.dart'; +import 'package:omi/ui/adaptive_widget.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; + +class OmiSessionTile extends AdaptiveWidget { + final String title; + final String subtitle; + final bool isActive; + final VoidCallback onTap; + final VoidCallback? onDelete; + final bool showDeleteButton; + + const OmiSessionTile({ + super.key, + required this.title, + required this.subtitle, + required this.isActive, + required this.onTap, + this.onDelete, + this.showDeleteButton = true, + }); + + @override + Widget buildDesktop(BuildContext context) => _tile(); + + @override + Widget buildMobile(BuildContext context) => _tile(); + + Widget _tile() { + return Container( + margin: const EdgeInsets.only(bottom: 8), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isActive + ? ResponsiveHelper.purplePrimary.withValues(alpha: 0.15) + : ResponsiveHelper.backgroundTertiary.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isActive + ? ResponsiveHelper.purplePrimary.withValues(alpha: 0.3) + : Colors.transparent, + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.chat_bubble_outline_rounded, + color: ResponsiveHelper.textSecondary, + size: 16, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: isActive + ? ResponsiveHelper.purplePrimary + : ResponsiveHelper.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + color: ResponsiveHelper.textTertiary, + fontSize: 11, + ), + ), + ], + ), + ), + if (showDeleteButton && onDelete != null) ...[ + const SizedBox(width: 8), + // Use different sizes for mobile vs desktop + LayoutBuilder( + builder: (context, constraints) { + // Check if we're on mobile by looking at screen width + final isMobile = MediaQuery.of(context).size.width < 600; + return OmiIconButton( + icon: Icons.delete_outline, + onPressed: onDelete!, + style: OmiIconButtonStyle.neutral, + size: isMobile ? 40 : 24, + iconSize: isMobile ? 18 : 14, + borderRadius: isMobile ? 8 : 4, + ); + }, + ), + ], + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file From f99c47fc731ea8d82c437463d2c0a3b5ac2f4c87 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Sat, 19 Jul 2025 21:15:46 +0530 Subject: [PATCH 8/9] session history on mobile --- app/ios/Podfile.lock | 46 ++-- app/ios/Runner.xcodeproj/project.pbxproj | 10 +- .../xcshareddata/xcschemes/dev.xcscheme | 2 + app/lib/pages/chat/page.dart | 20 +- app/lib/pages/chat/sessions_history_page.dart | 218 ++++++++++++++++++ app/lib/pages/home/page.dart | 88 ++++--- app/lib/providers/chat_session_provider.dart | 2 +- app/lib/providers/message_provider.dart | 70 ++---- 8 files changed, 339 insertions(+), 117 deletions(-) create mode 100644 app/lib/pages/chat/sessions_history_page.dart diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index d1bf59fd3a6..0d780c6d1ae 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -427,20 +427,20 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" SPEC CHECKSUMS: - app_links: f3e17e4ee5e357b39d8b95290a9b2c299fca71c6 + app_links: 76b66b60cc809390ca1ad69bfd66b998d2387ac7 AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73 - audio_session: 19e9480dbdd4e5f6c4543826b2e8b0e4ab6145fe - awesome_notifications: dd5518ff1c80be03d4f1c40f04da9d9cc2a37af5 - awesome_notifications_core: d02eed89738fa362d56cbd372850e9adcd2c6bef - connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d - device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d + audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 + awesome_notifications: 0f432b28098d193920b11a44cfa9d2d9313a3888 + awesome_notifications_core: 429c28df8746780a474de177e5acde33af87da63 + connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 - file_picker: b159e0c068aef54932bb15dc9fd1571818edaf49 + file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Firebase: 1fe1c0a7d9aaea32efe01fbea5f0ebd8d70e53a2 - firebase_auth: 3f532201cbdc7cd6dfc3bfa89affc0c294111e20 - firebase_core: 432718558359a8c08762151b5f49bb0f093eb6e0 - firebase_messaging: 3b99522baf7480dfb4b7683d2b34e842d577c362 + firebase_auth: 83bf106e5ac670dd3a0af27a86be6cba16a85723 + firebase_core: 2d4534e7b489907dcede540c835b48981d890943 + firebase_messaging: 75bc93a4df25faccad67f6662ae872ac9ae69b64 FirebaseAppCheckInterop: a92ba81d0ee3c4cddb1a2e52c668ea51dc63c3ae FirebaseAuth: c4146bdfdc87329f9962babd24dae89373f49a32 FirebaseAuthInterop: e25b58ecb90f3285085fa2118861a3c9dfdc62ad @@ -450,13 +450,13 @@ SPEC CHECKSUMS: FirebaseInstallations: 9980995bdd06ec8081dfb6ab364162bdd64245c3 FirebaseMessaging: 2b9f56aa4ed286e1f0ce2ee1d413aabb8f9f5cb9 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 - flutter_archive: cb3e0219e555897ba4b36f166baa1eca394890b9 - flutter_background_service_ios: e30e0d3ee69e4cee66272d0c78eacd48c2e94aac - flutter_blue_plus: 4837da7d00cf5d441fdd6635b3a57f936778ea96 - flutter_foreground_task: 21ef182ab0a29a3005cc72cd70e5f45cb7f7f817 - flutter_native_splash: f71420956eb811e6d310720fee915f1d42852e7a - flutter_silero_vad: bcad5bcce50bd7f63b772ad3f46f9ce1995dd833 - flutter_sound: 82aba29055d6feba684d08906e0623217b87bcd3 + flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe + flutter_background_service_ios: 00d31bdff7b4bfe06d32375df358abe0329cf87e + flutter_blue_plus: e5808fc4e5ebc58bb911635f8fdaf5e2b4da2754 + flutter_foreground_task: a159d2c2173b33699ddb3e6c2a067045d7cebb89 + flutter_native_splash: 6cad9122ea0fad137d23137dd14b937f3e90b145 + flutter_silero_vad: 623c22e30420ae174857926385670b9f6e52e4b9 + flutter_sound: b9236a5875299aaa4cef1690afd2f01d52a3f890 flutter_sound_core: 427465f72d07ab8c3edbe8ffdde709ddacd3763c flutter_timezone: ee50ce7786b5fde27e2fe5375bbc8c9661ffc13f frame_sdk: 4d4df786d828557bf57e05f6f1856613896cc9db @@ -468,7 +468,7 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 - image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a Instabug: 97a4e694731f46bbc02dbe49ab29cc552c5e2f41 instabug_flutter: 0a2d35be020c80b2b63bd8337a94a3f2ffe65bc0 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e @@ -481,9 +481,9 @@ SPEC CHECKSUMS: map_launcher: fe43bda6720bb73c12fcc1bdd86123ff49a4d4d6 mcumgr_flutter: 8c4a598cb1b4d10a9adbc0f13288334297185506 Mixpanel-swift: 7b26468fc0e2e521104e51d65c4bbf7cab8162f8 - mixpanel_flutter: c2bb8345c90bef15512a1b812ec800b52f8614b6 + mixpanel_flutter: a0b6b937035899cd01951735ad5f87718b2ffee5 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 - nordic_dfu: 963e2e4e6afb04e515ff43c546e6f8abf3e04ed5 + nordic_dfu: e4fb6f461f4a290b28ea4b1dfb69071665cdfa3e onnxruntime-c: e87399683ec19e3b812e13c6692882609a802b86 onnxruntime-objc: 57ae8f83779a4c32731065d50d02d042af581114 opus_flutter_ios: f16ed3599997ced564ad44509e87003159a86def @@ -502,10 +502,10 @@ SPEC CHECKSUMS: SwiftCBOR: ce5354ec8b660da2d6fc754462881119dbe1f963 SwiftProtobuf: b7aa08087e2ab6d162862d143020091254095f69 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 - url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe - webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c PODFILE CHECKSUM: 0ff3dedbc65a62aff6be5119a19cb4fd9e15742d -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index 963da43bd33..3ff5296707a 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -302,14 +302,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -394,14 +390,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme index d6d589e2f65..dcdf1f4755e 100644 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme +++ b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/dev.xcscheme @@ -10,6 +10,7 @@ buildConfiguration = "Debug-dev" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> with AutomaticKeepAliveClientMixin { }); SchedulerBinding.instance.addPostFrameCallback((_) async { var provider = context.read(); + var sessionProvider = context.read(); + + // Initialize sessions + await sessionProvider.loadSessions(); + + // Refresh messages for the current session if (provider.messages.isEmpty) { - provider.refreshMessages(); + provider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); } scrollToBottom(); }); @@ -100,12 +107,14 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { super.dispose(); } - @override + + + @override Widget build(BuildContext context) { super.build(context); - return Consumer2( - builder: (context, provider, connectivityProvider, child) { + return Consumer3( + builder: (context, provider, connectivityProvider, sessionProvider, child) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.primary, appBar: provider.isLoadingMessages @@ -605,11 +614,12 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { _sendMessageUtil(String text) { var provider = context.read(); + var sessionProvider = context.read(); provider.setSendingMessage(true); provider.addMessageLocally(text); scrollToBottom(); textController.clear(); - provider.sendMessageStreamToServer(text, context: context); + provider.sendMessageStreamToServer(text, chatSessionId: sessionProvider.currentSessionId, context: context); provider.clearSelectedFiles(); provider.setSendingMessage(false); } diff --git a/app/lib/pages/chat/sessions_history_page.dart b/app/lib/pages/chat/sessions_history_page.dart new file mode 100644 index 00000000000..d45af7254cf --- /dev/null +++ b/app/lib/pages/chat/sessions_history_page.dart @@ -0,0 +1,218 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:omi/backend/schema/chat_session.dart'; +import 'package:omi/providers/chat_session_provider.dart'; +import 'package:omi/providers/message_provider.dart'; +import 'package:omi/providers/app_provider.dart'; +import 'package:omi/ui/molecules/omi_session_tile.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:omi/utils/responsive/responsive_helper.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +class MobileSessionsHistoryPage extends StatefulWidget { + const MobileSessionsHistoryPage({super.key}); + + @override + State createState() => _MobileSessionsHistoryPageState(); +} + +class _MobileSessionsHistoryPageState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Ensure sessions are loaded + context.read().loadSessions(); + }); + } + + void _handleSessionTap(ChatSession session) async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Switch to the selected session + sessionProvider.switchToSession(session); + + // Refresh messages for the selected session + messageProvider.refreshMessages(chatSessionId: session.id); + + // Navigate back to chat + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _handleDeleteSession(ChatSession session) async { + final result = await OmiConfirmDialog.show( + context, + title: 'Delete Session?', + message: 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + confirmLabel: 'Delete', + ); + + if (result == true && mounted) { + final sessionProvider = context.read(); + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: Theme.of(context).colorScheme.secondary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + + void _handleCreateNewSession() async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + Navigator.of(context).pop(); + + await sessionProvider.createNewSession(); + + // Refresh messages for the new session + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + + } + + @override + Widget build(BuildContext context) { + return Consumer3( + builder: (context, sessionProvider, appProvider, messageProvider, child) { + return Scaffold( + backgroundColor: Theme.of(context).colorScheme.primary, + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.surface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: const Text( + 'Chat Sessions', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(FontAwesomeIcons.plus, color: Colors.white, size: 18), + onPressed: _handleCreateNewSession, + tooltip: 'New Session', + ), + ], + ), + body: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : sessionProvider.sessions.isEmpty + ? _buildEmptyState() + : _buildSessionsList(sessionProvider), + ); + }, + ); + } + + Widget _buildEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + FontAwesomeIcons.comments, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + Text( + 'No chat sessions yet', + style: TextStyle( + color: Colors.grey.shade300, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Start a new conversation to create your first session', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _handleCreateNewSession, + icon: const Icon(FontAwesomeIcons.plus, size: 16), + label: const Text('Start New Chat'), + style: ElevatedButton.styleFrom( + backgroundColor: ResponsiveHelper.purplePrimary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSessionsList(ChatSessionProvider sessionProvider) { + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: sessionProvider.sessions.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isActive = session.id == sessionProvider.currentSessionId; + + return OmiSessionTile( + title: session.displayTitle, + subtitle: _formatSessionTime(session.createdAt), + isActive: isActive, + onTap: () => _handleSessionTap(session), + onDelete: sessionProvider.sessions.length > 1 + ? () => _handleDeleteSession(session) + : null, + showDeleteButton: sessionProvider.sessions.length > 1, + ); + }, + ); + } + + String _formatSessionTime(DateTime createdAt) { + final now = DateTime.now(); + final difference = now.difference(createdAt); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } + } +} \ No newline at end of file diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 1f9e2afb39a..975ca25a982 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -14,6 +14,7 @@ import 'package:omi/gen/assets.gen.dart'; import 'package:omi/main.dart'; import 'package:omi/pages/action_items/action_items_page.dart'; import 'package:omi/pages/apps/page.dart'; +import 'package:omi/pages/chat/sessions_history_page.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/conversations/conversations_page.dart'; import 'package:omi/pages/home/widgets/chat_apps_dropdown_widget.dart'; @@ -22,6 +23,7 @@ import 'package:omi/pages/settings/data_privacy_page.dart'; import 'package:omi/pages/settings/page.dart'; import 'package:omi/providers/app_provider.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/device_provider.dart'; @@ -645,37 +647,63 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } }, ), - Row( - children: [ - Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.transparent, - ), - child: IconButton( - padding: const EdgeInsets.fromLTRB(2.0, 2.0, 0, 2.0), - icon: SvgPicture.asset( - Assets.images.icSettingPersona, - width: 36, - height: 36, + Consumer( + builder: (context, homeProvider, child) { + return Row( + children: [ + Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: homeProvider.selectedIndex == 1 // Chat tab selected + ? Consumer( + builder: (context, sessionProvider, child) { + return IconButton( + padding: const EdgeInsets.fromLTRB(2.0, 2.0, 0, 2.0), + icon: const Icon( + FontAwesomeIcons.clockRotateLeft, + color: Colors.white, + size: 20, + ), + onPressed: () { + MixpanelManager().pageOpened('Chat Sessions'); + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const MobileSessionsHistoryPage(), + ), + ); + }, + tooltip: 'Chat History', + ); + }, + ) + : IconButton( + padding: const EdgeInsets.fromLTRB(2.0, 2.0, 0, 2.0), + icon: SvgPicture.asset( + Assets.images.icSettingPersona, + width: 36, + height: 36, + ), + onPressed: () { + MixpanelManager().pageOpened('Settings'); + String language = SharedPreferencesUtil().userPrimaryLanguage; + bool hasSpeech = SharedPreferencesUtil().hasSpeakerProfile; + String transcriptModel = SharedPreferencesUtil().transcriptionModel; + routeToPage(context, const SettingsPage()); + if (language != SharedPreferencesUtil().userPrimaryLanguage || + hasSpeech != SharedPreferencesUtil().hasSpeakerProfile || + transcriptModel != SharedPreferencesUtil().transcriptionModel) { + if (context.mounted) { + context.read().onRecordProfileSettingChanged(); + } + } + }, + ), ), - onPressed: () { - MixpanelManager().pageOpened('Settings'); - String language = SharedPreferencesUtil().userPrimaryLanguage; - bool hasSpeech = SharedPreferencesUtil().hasSpeakerProfile; - String transcriptModel = SharedPreferencesUtil().transcriptionModel; - routeToPage(context, const SettingsPage()); - if (language != SharedPreferencesUtil().userPrimaryLanguage || - hasSpeech != SharedPreferencesUtil().hasSpeakerProfile || - transcriptModel != SharedPreferencesUtil().transcriptionModel) { - if (context.mounted) { - context.read().onRecordProfileSettingChanged(); - } - } - }, - ), - ), - ], + ], + ); + }, ), ], ), diff --git a/app/lib/providers/chat_session_provider.dart b/app/lib/providers/chat_session_provider.dart index 237e2a95e7c..bd89335a9c0 100644 --- a/app/lib/providers/chat_session_provider.dart +++ b/app/lib/providers/chat_session_provider.dart @@ -75,7 +75,7 @@ class ChatSessionProvider extends ChangeNotifier { Future createNewSession({String? title}) async { try { final appId = appProvider?.selectedChatAppId; - final newSession = await createChatSession(appId: appId, title: title); + final newSession = await createChatSession(appId: appId); if (newSession != null) { sessions.insert(0, newSession); diff --git a/app/lib/providers/message_provider.dart b/app/lib/providers/message_provider.dart index b14d100c65d..2a2acf4ebff 100644 --- a/app/lib/providers/message_provider.dart +++ b/app/lib/providers/message_provider.dart @@ -498,9 +498,16 @@ class MessageProvider extends ChangeNotifier { setShowTypingIndicator(false); - // Update session title if this is the first user message and we have a context if (messageSuccessful && chatSessionId != null && context != null) { - await _updateSessionTitleIfNeeded(chatSessionId, text, context); + List userMessages = messages.where((m) => m.sender == MessageSender.human).toList(); + + if (userMessages.length <= 2) { + Future.delayed(const Duration(milliseconds: 1500), () { + if (context.mounted) { + _refreshSessionTitleFromBackend(chatSessionId, context); + } + }); + } } } @@ -516,60 +523,25 @@ class MessageProvider extends ChangeNotifier { return appProvider?.apps.firstWhereOrNull((p) => p.id == appId); } - String _generateTitleFromMessage(String message) { - // Remove extra whitespace and split into words - List words = message.trim().split(RegExp(r'\s+')); - - // Filter out very short words and common words - List meaningfulWords = words - .where((word) => word.length > 2) - .where((word) => !['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how', 'man', 'new', 'now', 'old', 'see', 'two', 'way', 'who', 'boy', 'did', 'its', 'let', 'put', 'say', 'she', 'too', 'use'].contains(word.toLowerCase())) - .toList(); - - // Take first 3-5 meaningful words - List titleWords = meaningfulWords.take(4).toList(); - - // If we don't have enough meaningful words, use the first few words - if (titleWords.length < 2) { - titleWords = words.take(4).toList(); - } - - // Capitalize first letter of each word - titleWords = titleWords.map((word) => word[0].toUpperCase() + word.substring(1).toLowerCase()).toList(); + Future _refreshSessionTitleFromBackend(String chatSessionId, flutter.BuildContext? context) async { + if (context == null) return; - String title = titleWords.join(' '); - - // Limit length to 50 characters - if (title.length > 50) { - title = title.substring(0, 47) + '...'; - } - - return title; - } - - Future _updateSessionTitleIfNeeded(String chatSessionId, String messageText, flutter.BuildContext context) async { try { - // Check if this is the first user message in the session - List userMessages = messages.where((m) => m.sender == MessageSender.human).toList(); + final sessionProvider = context.read(); + final currentSession = sessionProvider.currentSession; - if (userMessages.length == 1) { - // This is the first user message, generate and update title - String title = _generateTitleFromMessage(messageText); + if (currentSession != null && currentSession.id == chatSessionId) { + final updatedSession = await getChatSessionById(chatSessionId); - // Update title in backend - bool success = await updateChatSessionTitle(chatSessionId, title); - - if (success) { - // Update title in session provider - final sessionProvider = context.read(); - final currentSession = sessionProvider.currentSession; - if (currentSession != null && currentSession.id == chatSessionId) { - await sessionProvider.updateSessionTitle(currentSession, title); - } + if (updatedSession != null && updatedSession.title != null && + updatedSession.title!.isNotEmpty && !updatedSession.title!.startsWith('New Chat')) { + // Update the session in the provider with the AI-generated title + await sessionProvider.updateSessionTitle(currentSession, updatedSession.title!); + debugPrint('Refreshed session title from backend: ${updatedSession.title}'); } } } catch (e) { - debugPrint('Error updating session title: $e'); + debugPrint('Error refreshing session title from backend: $e'); } } } From a662686d7dca16153cffe7806f965cd6821cdce1 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 23 Jul 2025 18:49:36 +0530 Subject: [PATCH 9/9] use ssions drawer ui --- app/ios/Podfile.lock | 13 + app/lib/pages/chat/page.dart | 560 +++++++++++------- app/lib/pages/chat/sessions_history_page.dart | 296 ++++++--- app/pubspec.lock | 72 +++ 4 files changed, 643 insertions(+), 298 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index 0d780c6d1ae..72c1b64dca5 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -173,6 +173,8 @@ PODS: - GTMSessionFetcher/Core (3.5.0) - image_picker_ios (0.0.1): - Flutter + - in_app_review (2.0.0): + - Flutter - Instabug (14.3.0) - instabug_flutter (14.3.1): - Flutter @@ -250,6 +252,9 @@ PODS: - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS - webview_flutter_wkwebview (0.0.1): - Flutter - FlutterMacOS @@ -280,6 +285,7 @@ DEPENDENCIES: - google_sign_in_all_platforms_mobile (from `.symlinks/plugins/google_sign_in_all_platforms_mobile/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - in_app_review (from `.symlinks/plugins/in_app_review/ios`) - instabug_flutter (from `.symlinks/plugins/instabug_flutter/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - intercom_flutter (from `.symlinks/plugins/intercom_flutter/ios`) @@ -298,6 +304,7 @@ DEPENDENCIES: - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) SPEC REPOS: @@ -387,6 +394,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_sign_in_ios/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + in_app_review: + :path: ".symlinks/plugins/in_app_review/ios" instabug_flutter: :path: ".symlinks/plugins/instabug_flutter/ios" integration_test: @@ -423,6 +432,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/darwin" webview_flutter_wkwebview: :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" @@ -469,6 +480,7 @@ SPEC CHECKSUMS: GTMAppAuth: 99fb010047ba3973b7026e45393f51f27ab965ae GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a + in_app_review: 5596fe56fab799e8edb3561c03d053363ab13457 Instabug: 97a4e694731f46bbc02dbe49ab29cc552c5e2f41 instabug_flutter: 0a2d35be020c80b2b63bd8337a94a3f2ffe65bc0 integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e @@ -503,6 +515,7 @@ SPEC CHECKSUMS: SwiftProtobuf: b7aa08087e2ab6d162862d143020091254095f69 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d + video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b webview_flutter_wkwebview: 1821ceac936eba6f7984d89a9f3bcb4dea99ebb2 ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 2f92614eb72..60ca2cdefb4 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -12,14 +12,13 @@ import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/app.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/message.dart'; +import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/gen/assets.gen.dart'; import 'package:omi/pages/chat/select_text_screen.dart'; import 'package:omi/pages/chat/widgets/ai_message.dart'; -import 'package:omi/pages/chat/widgets/animated_mini_banner.dart'; import 'package:omi/pages/chat/widgets/user_message.dart'; import 'package:omi/pages/chat/widgets/voice_recorder_widget.dart'; import 'package:omi/pages/home/page.dart'; -import 'package:omi/pages/home/widgets/chat_apps_dropdown_widget.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; @@ -139,7 +138,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { key: scaffoldKey, backgroundColor: Theme.of(context).colorScheme.primary, appBar: _buildAppBar(context, provider), - endDrawer: _buildHistoryDrawer(context), + endDrawer: _buildSessionsDrawer(context), body: GestureDetector( onTap: () { // Hide keyboard when tapping outside textfield @@ -317,7 +316,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { // Send message area - fixed at bottom Container( margin: const EdgeInsets.only(top: 10), - decoration: BoxDecoration( + decoration: const BoxDecoration( color: Color(0xFF1f1f25), borderRadius: BorderRadius.only( topLeft: Radius.circular(22), @@ -519,7 +518,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { height: 44, width: 32, alignment: Alignment.center, - child: FaIcon( + child: const FaIcon( FontAwesomeIcons.microphone, color: Colors.white, size: 20, @@ -572,7 +571,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { ), ], ), - child: Icon( + child: const Icon( FontAwesomeIcons.arrowUp, color: Color(0xFF35343B), size: 18, @@ -778,6 +777,8 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { icon: const Icon(Icons.history, color: Colors.white), onPressed: () { HapticFeedback.mediumImpact(); + // Dismiss keyboard before opening drawer + FocusScope.of(context).unfocus(); scaffoldKey.currentState?.openEndDrawer(); }, ), @@ -1137,248 +1138,351 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin { height: 0.5, color: Colors.grey.shade700, margin: const EdgeInsets.symmetric(horizontal: 20), - ); + ); } - Widget _buildHistoryDrawer(BuildContext context) { - // Dummy chat history data - final List> chatHistory = [ - { - 'title': 'Travel Planning Discussion', - 'subtitle': 'Help me plan a trip to Japan...', - 'timestamp': 'Today 2:30 PM', - 'appName': 'Travel Assistant', - 'appColor': Colors.blue, - 'isSelected': true, - }, - { - 'title': 'Code Review Session', - 'subtitle': 'Can you review my Flutter code?', - 'timestamp': 'Today 10:45 AM', - 'appName': 'Code Helper', - 'appColor': Colors.green, - 'isSelected': false, - }, - { - 'title': 'Recipe Suggestions', - 'subtitle': 'What can I cook with chicken?', - 'timestamp': 'Yesterday 6:20 PM', - 'appName': 'Chef Bot', - 'appColor': Colors.orange, - 'isSelected': false, - }, - { - 'title': 'Learning Discussion', - 'subtitle': 'Explain machine learning basics', - 'timestamp': 'Yesterday 2:15 PM', - 'appName': 'Omi', - 'appColor': Colors.purple, - 'isSelected': false, - }, - { - 'title': 'Book Recommendations', - 'subtitle': 'Suggest some sci-fi novels', - 'timestamp': '2 days ago', - 'appName': 'Book Guru', - 'appColor': Colors.red, - 'isSelected': false, - }, - { - 'title': 'Workout Planning', - 'subtitle': 'Create a weekly exercise routine', - 'timestamp': '3 days ago', - 'appName': 'Fitness Pro', - 'appColor': Colors.teal, - 'isSelected': false, - }, - { - 'title': 'Career Advice Chat', - 'subtitle': 'Should I switch to tech?', - 'timestamp': '1 week ago', - 'appName': 'Career Coach', - 'appColor': Colors.indigo, - 'isSelected': false, - }, - { - 'title': 'Creative Writing Help', - 'subtitle': 'Help with story plot ideas', - 'timestamp': '1 week ago', - 'appName': 'Story Helper', - 'appColor': Colors.pink, - 'isSelected': false, + Widget _buildSessionsDrawer(BuildContext context) { + return Consumer( + builder: (context, sessionProvider, child) { + return Theme( + data: Theme.of(context).copyWith( + canvasColor: Colors.white.withOpacity(0.1), + ), + child: SizedBox( + width: MediaQuery.of(context).size.width * 0.85, + child: Drawer( + backgroundColor: Theme.of(context).colorScheme.primary, + child: Column( + children: [ + // Search bar and header + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16, + left: 12, + right: 12, + bottom: 6, + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + enabled: false, + readOnly: true, + focusNode: FocusNode()..canRequestFocus = false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search chats...', + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 16, + ), + prefixIcon: Icon( + Icons.search, + color: Colors.white.withOpacity(0.6), + size: 20, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => _handleCreateNewSession(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.plus, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), + ), + // Content + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : sessionProvider.sessions.isEmpty + ? _buildDrawerEmptyState() + : _buildDrawerSessionsList(sessionProvider), + ), + ], + ), + ), + ), + ); }, - ]; + ); + } - return Theme( - data: Theme.of(context).copyWith( - canvasColor: Colors.white.withOpacity(0.1), + Widget _buildDrawerEmptyState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + FontAwesomeIcons.comments, + size: 64, + color: Colors.grey.shade600, + ), + const SizedBox(height: 16), + Text( + 'No chat sessions yet', + style: TextStyle( + color: Colors.grey.shade300, + fontSize: 18, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Text( + 'Start a new conversation to create your first session', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + GestureDetector( + onTap: () => _handleCreateNewSession(), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + FaIcon(FontAwesomeIcons.plus, size: 16, color: Colors.black), + SizedBox(width: 8), + Text( + 'Start New Chat', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ], ), - child: SizedBox( - width: MediaQuery.of(context).size.width * 0.85, - child: Drawer( - backgroundColor: Theme.of(context).colorScheme.primary, - child: Column( - children: [ - // Search bar - Container( - padding: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16, - left: 12, - right: 12, - bottom: 6, + ); + } + + Widget _buildDrawerSessionsList(ChatSessionProvider sessionProvider) { + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: sessionProvider.sessions.length, + itemBuilder: (context, index) { + final session = sessionProvider.sessions[index]; + final isSelected = session.id == sessionProvider.currentSessionId; + final sessionColor = _getSessionColor(index); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleSessionTap(session), + onLongPress: sessionProvider.sessions.length > 1 + ? () => _handleDeleteSession(session) + : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2A282A) : const Color(0xFF151415), + borderRadius: BorderRadius.circular(16), ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: TextField( - enabled: false, - style: const TextStyle(color: Colors.white), - decoration: InputDecoration( - hintText: 'Search chats...', - hintStyle: TextStyle( - color: Colors.white.withOpacity(0.6), - fontSize: 16, - ), - prefixIcon: Icon( - Icons.search, - color: Colors.white.withOpacity(0.6), - size: 20, - ), - border: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: sessionColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle[0].toUpperCase() + : 'C', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), ), - ), - ), - const SizedBox(width: 12), - GestureDetector( - onTap: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('New chat functionality coming soon!'), - duration: Duration(seconds: 1), + const SizedBox(width: 12), + Expanded( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle + : 'Chat Session', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ); - }, - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: Color(0xFF151415), - borderRadius: BorderRadius.circular(12), ), - child: const Center( - child: FaIcon( - FontAwesomeIcons.plus, - color: Colors.white, - size: 18, + if (isSelected) + const SizedBox( + width: 24, + child: Icon(Icons.check, color: Colors.white60, size: 16), ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + _formatSessionTime(session.createdAt), + style: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 12, ), ), ), ], ), ), - // Content - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: chatHistory.length, - itemBuilder: (context, index) { - final chat = chatHistory[index]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - // TODO: Navigate to specific chat - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Opening: ${chat['title']}'), - duration: const Duration(seconds: 1), - ), - ); - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: chat['isSelected'] ? Color(0xFF2A282A) : Color(0xFF151415), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: chat['appColor'], - shape: BoxShape.circle, - ), - child: Center( - child: Text( - chat['appName'][0].toUpperCase(), - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - chat['title'], - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 32), - child: Text( - chat['timestamp'], - style: TextStyle( - color: Colors.white.withOpacity(0.5), - fontSize: 12, - ), - ), - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], + ), ), - ), - ), + ); + }, + ); + } + + void _handleSessionTap(ChatSession session) async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Switch to the selected session + sessionProvider.switchToSession(session); + + // Refresh messages for the selected session + messageProvider.refreshMessages(chatSessionId: session.id); + + // Close the drawer + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _handleDeleteSession(ChatSession session) async { + final result = await showDialog( + context: context, + builder: (context) { + return getDialog( + context, + () => Navigator.of(context).pop(false), + () => Navigator.of(context).pop(true), + 'Delete Session?', + 'Are you sure you want to delete the chat session "${session.displayTitle}"? This action cannot be undone.', + ); + }, ); + + if (result == true && mounted) { + final sessionProvider = context.read(); + await sessionProvider.deleteSession(session); + + // Refresh messages for the new current session + final messageProvider = context.read(); + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Chat session "${session.displayTitle}" deleted'), + backgroundColor: Theme.of(context).colorScheme.secondary, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + duration: const Duration(seconds: 3), + ), + ); + } + } } + + void _handleCreateNewSession() async { + final sessionProvider = context.read(); + final messageProvider = context.read(); + + // Close the drawer first + Navigator.of(context).pop(); + + await sessionProvider.createNewSession(); + + // Refresh messages for the new session + await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + } + + String _formatSessionTime(DateTime createdAt) { + final now = DateTime.now(); + final difference = now.difference(createdAt); + + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } + } + + Color _getSessionColor(int index) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.indigo, + Colors.pink, + ]; + return colors[index % colors.length]; + } + } diff --git a/app/lib/pages/chat/sessions_history_page.dart b/app/lib/pages/chat/sessions_history_page.dart index d45af7254cf..39cbd8483a2 100644 --- a/app/lib/pages/chat/sessions_history_page.dart +++ b/app/lib/pages/chat/sessions_history_page.dart @@ -4,9 +4,7 @@ import 'package:omi/backend/schema/chat_session.dart'; import 'package:omi/providers/chat_session_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/providers/app_provider.dart'; -import 'package:omi/ui/molecules/omi_session_tile.dart'; import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; -import 'package:omi/utils/responsive/responsive_helper.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; class MobileSessionsHistoryPage extends StatefulWidget { @@ -82,7 +80,37 @@ class _MobileSessionsHistoryPageState extends State { // Refresh messages for the new session await messageProvider.refreshMessages(chatSessionId: sessionProvider.currentSessionId); + } + + String _formatSessionTime(DateTime createdAt) { + final now = DateTime.now(); + final difference = now.difference(createdAt); + if (difference.inMinutes < 1) { + return 'Just now'; + } else if (difference.inMinutes < 60) { + return '${difference.inMinutes}m ago'; + } else if (difference.inHours < 24) { + return '${difference.inHours}h ago'; + } else if (difference.inDays < 7) { + return '${difference.inDays}d ago'; + } else { + return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; + } + } + + Color _getSessionColor(int index) { + final colors = [ + Colors.blue, + Colors.green, + Colors.orange, + Colors.purple, + Colors.red, + Colors.teal, + Colors.indigo, + Colors.pink, + ]; + return colors[index % colors.length]; } @override @@ -91,39 +119,103 @@ class _MobileSessionsHistoryPageState extends State { builder: (context, sessionProvider, appProvider, messageProvider, child) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.primary, - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.surface, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.arrow_back_ios, color: Colors.white), - onPressed: () => Navigator.of(context).pop(), - ), - title: const Text( - 'Chat Sessions', - style: TextStyle( - color: Colors.white, - fontSize: 18, - fontWeight: FontWeight.w600, + body: Column( + children: [ + // Search bar and header + Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + 16, + left: 12, + right: 12, + bottom: 6, + ), + child: Row( + children: [ + // Back button + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: Icon( + Icons.arrow_back_ios, + color: Colors.white, + size: 18, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: TextField( + enabled: false, + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search chats...', + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.6), + fontSize: 16, + ), + prefixIcon: Icon( + Icons.search, + color: Colors.white.withOpacity(0.6), + size: 20, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: _handleCreateNewSession, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF151415), + borderRadius: BorderRadius.circular(12), + ), + child: const Center( + child: FaIcon( + FontAwesomeIcons.plus, + color: Colors.white, + size: 18, + ), + ), + ), + ), + ], + ), ), - ), - centerTitle: true, - actions: [ - IconButton( - icon: const Icon(FontAwesomeIcons.plus, color: Colors.white, size: 18), - onPressed: _handleCreateNewSession, - tooltip: 'New Session', + // Content + Expanded( + child: sessionProvider.isLoadingSessions + ? const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ) + : sessionProvider.sessions.isEmpty + ? _buildEmptyState() + : _buildSessionsList(sessionProvider), ), ], ), - body: sessionProvider.isLoadingSessions - ? const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(Colors.white), - ), - ) - : sessionProvider.sessions.isEmpty - ? _buildEmptyState() - : _buildSessionsList(sessionProvider), ); }, ); @@ -158,16 +250,27 @@ class _MobileSessionsHistoryPageState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _handleCreateNewSession, - icon: const Icon(FontAwesomeIcons.plus, size: 16), - label: const Text('Start New Chat'), - style: ElevatedButton.styleFrom( - backgroundColor: ResponsiveHelper.purplePrimary, - foregroundColor: Colors.white, + GestureDetector( + onTap: _handleCreateNewSession, + child: Container( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + FaIcon(FontAwesomeIcons.plus, size: 16, color: Colors.black), + SizedBox(width: 8), + Text( + 'Start New Chat', + style: TextStyle( + color: Colors.black, + fontWeight: FontWeight.w600, + ), + ), + ], ), ), ), @@ -177,42 +280,95 @@ class _MobileSessionsHistoryPageState extends State { } Widget _buildSessionsList(ChatSessionProvider sessionProvider) { - return ListView.separated( - padding: const EdgeInsets.all(16), + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: sessionProvider.sessions.length, - separatorBuilder: (context, index) => const SizedBox(height: 8), itemBuilder: (context, index) { final session = sessionProvider.sessions[index]; - final isActive = session.id == sessionProvider.currentSessionId; + final isSelected = session.id == sessionProvider.currentSessionId; + final sessionColor = _getSessionColor(index); - return OmiSessionTile( - title: session.displayTitle, - subtitle: _formatSessionTime(session.createdAt), - isActive: isActive, - onTap: () => _handleSessionTap(session), - onDelete: sessionProvider.sessions.length > 1 - ? () => _handleDeleteSession(session) - : null, - showDeleteButton: sessionProvider.sessions.length > 1, + return Container( + margin: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => _handleSessionTap(session), + onLongPress: sessionProvider.sessions.length > 1 + ? () => _handleDeleteSession(session) + : null, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? const Color(0xFF2A282A) : const Color(0xFF151415), + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: sessionColor, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle[0].toUpperCase() + : 'C', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + session.displayTitle.isNotEmpty + ? session.displayTitle + : 'Chat Session', + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (isSelected) + const SizedBox( + width: 24, + child: Icon(Icons.check, color: Colors.white60, size: 16), + ), + ], + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.only(left: 32), + child: Text( + _formatSessionTime(session.createdAt), + style: TextStyle( + color: Colors.white.withOpacity(0.5), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ), ); }, ); } - - String _formatSessionTime(DateTime createdAt) { - final now = DateTime.now(); - final difference = now.difference(createdAt); - - if (difference.inMinutes < 1) { - return 'Just now'; - } else if (difference.inMinutes < 60) { - return '${difference.inMinutes}m ago'; - } else if (difference.inHours < 24) { - return '${difference.inHours}h ago'; - } else if (difference.inDays < 7) { - return '${difference.inDays}d ago'; - } else { - return '${createdAt.day}/${createdAt.month}/${createdAt.year}'; - } - } } \ No newline at end of file diff --git a/app/pubspec.lock b/app/pubspec.lock index 2309498fd91..97d3d8acf38 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -1157,6 +1157,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + in_app_review: + dependency: "direct main" + description: + name: in_app_review + sha256: "36a06771b88fb0e79985b15e7f2ac0f1142e903fe72517f3c055d78bc3bc1819" + url: "https://pub.dev" + source: hosted + version: "2.0.10" + in_app_review_platform_interface: + dependency: transitive + description: + name: in_app_review_platform_interface + sha256: fed2c755f2125caa9ae10495a3c163aa7fab5af3585a9c62ef4a6920c5b45f10 + url: "https://pub.dev" + source: hosted + version: "2.0.5" inject_js: dependency: transitive description: @@ -1757,6 +1773,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + pull_down_button: + dependency: "direct main" + description: + name: pull_down_button + sha256: "12cdd8ff187a3150ebdf075e5074299f085579b158d2b4e655ccbafccf95f25b" + url: "https://pub.dev" + source: hosted + version: "0.10.2" qr: dependency: transitive description: @@ -1917,6 +1941,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" + siri_wave: + dependency: "direct main" + description: + name: siri_wave + sha256: "0b92945d37f46e58ae3d5c1adb69879a0db12d656ba68537925d0a3ec759937c" + url: "https://pub.dev" + source: hosted + version: "2.3.0" skeletonizer: dependency: "direct main" description: @@ -2266,6 +2298,46 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "0d55b1f1a31e5ad4c4967bfaa8ade0240b07d20ee4af1dfef5f531056512961a" + url: "https://pub.dev" + source: hosted + version: "2.10.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "4a5135754a62dbc827a64a42ef1f8ed72c962e191c97e2d48744225c2b9ebb73" + url: "https://pub.dev" + source: hosted + version: "2.8.7" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: "9fedd55023249f3a02738c195c906b4e530956191febf0838e37d0dac912f953" + url: "https://pub.dev" + source: hosted + version: "2.8.0" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: cf2a1d29a284db648fd66cbd18aacc157f9862d77d2cc790f6f9678a46c1db5a + url: "https://pub.dev" + source: hosted + version: "6.4.0" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb" + url: "https://pub.dev" + source: hosted + version: "2.4.0" visibility_detector: dependency: "direct main" description: