From d1bb1f8ee7f32c9bb83f495ade67a0e988380f22 Mon Sep 17 00:00:00 2001 From: Krushna Kanta Rout <129386740+krushnarout@users.noreply.github.com> Date: Sun, 19 Oct 2025 05:06:58 +0530 Subject: [PATCH] Revert "feat: add chat tab to bottom navigation (#3114)" This reverts commit 5ce2a6a325065d300d9f0d5c36a3452d459651bf. --- app/lib/pages/apps/app_detail/app_detail.dart | 11 +- app/lib/pages/chat/page.dart | 404 ++++++++++++++++-- app/lib/pages/home/page.dart | 207 ++++++--- .../home/widgets/battery_info_widget.dart | 23 +- app/lib/pages/memories/page.dart | 12 +- app/lib/pages/settings/settings_drawer.dart | 16 - app/lib/providers/home_provider.dart | 16 +- app/lib/widgets/app_selection_dropdown.dart | 373 ---------------- 8 files changed, 536 insertions(+), 526 deletions(-) delete mode 100644 app/lib/widgets/app_selection_dropdown.dart diff --git a/app/lib/pages/apps/app_detail/app_detail.dart b/app/lib/pages/apps/app_detail/app_detail.dart index e68ac01f47..41aece0a64 100644 --- a/app/lib/pages/apps/app_detail/app_detail.dart +++ b/app/lib/pages/apps/app_detail/app_detail.dart @@ -15,7 +15,6 @@ import 'package:omi/pages/apps/markdown_viewer.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/apps/providers/add_app_provider.dart'; import 'package:omi/providers/app_provider.dart'; -import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/message_provider.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/other/temp.dart'; @@ -309,10 +308,14 @@ class _AppDetailPageState extends State { messageProvider.sendInitialAppMessage(selectedApp); } - // Navigate to chat tab + // Navigate directly to chat page if (mounted) { - Navigator.pop(context); - context.read().setIndex(2); + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatPage(isPivotBottom: false), + ), + ); } } finally { if (mounted) { diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index 4573237471..69eb9ddc57 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -1,21 +1,27 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:pull_down_button/pull_down_button.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/conversation.dart'; import 'package:omi/backend/schema/message.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/user_message.dart'; import 'package:omi/pages/chat/widgets/voice_recorder_widget.dart'; +import 'package:omi/pages/home/page.dart'; import 'package:omi/providers/connectivity_provider.dart'; 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/utils/alerts/app_snackbar.dart'; import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/utils/other/temp.dart'; @@ -52,6 +58,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, late List apps; final scaffoldKey = GlobalKey(); + final GlobalKey _appButtonKey = GlobalKey(); @override bool get wantKeepAlive => true; @@ -60,7 +67,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, void initState() { apps = prefs.appsList; scrollController = ScrollController(); - textFieldFocusNode = context.read().chatFieldFocusNode; + textFieldFocusNode = FocusNode(); textController.addListener(() { setState(() {}); }); @@ -112,6 +119,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, void dispose() { textController.dispose(); scrollController.dispose(); + textFieldFocusNode.dispose(); super.dispose(); } @@ -124,19 +132,18 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, return Scaffold( key: scaffoldKey, backgroundColor: Theme.of(context).colorScheme.primary, + appBar: _buildAppBar(context, provider), // endDrawer: _buildSessionsDrawer(context), body: GestureDetector( onTap: () { // Hide keyboard when tapping outside textfield FocusScope.of(context).unfocus(); }, - child: Stack( + child: Column( children: [ - Column( - children: [ - // Messages area - takes up remaining space - Expanded( - child: provider.isLoadingMessages && !provider.hasCachedMessages + // Messages area - takes up remaining space + Expanded( + child: provider.isLoadingMessages && !provider.hasCachedMessages ? Column( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -442,11 +449,7 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, left: 0, right: 16, top: provider.selectedFiles.isNotEmpty ? 0 : 16, - bottom: widget.isPivotBottom - ? 20 - : textFieldFocusNode.hasFocus - ? MediaQuery.of(context).viewInsets.bottom + 20 - : MediaQuery.of(context).padding.bottom + 80, + bottom: widget.isPivotBottom ? 20 : (textFieldFocusNode.hasFocus ? 20 : 40), ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, @@ -621,32 +624,9 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, ), ], ), - // Loading messages indicator - if (provider.isLoadingMessages) - Positioned( - top: MediaQuery.of(context).padding.top + 10, - left: 0, - right: 0, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 20), - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), - decoration: BoxDecoration( - color: Colors.green, - borderRadius: BorderRadius.circular(20), - ), - child: const Center( - child: Text( - 'Syncing messages with server...', - style: TextStyle(color: Colors.white, fontSize: 12), - ), - ), - ), - ), - ], - ), - ) - ); - }, + ), + ); + }, ); } @@ -694,6 +674,354 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, scrollToBottom() => _moveListToBottom(); + void _handleAppSelection(String? val, AppProvider provider) { + if (val == null || val == provider.selectedChatAppId) { + return; + } + + // Unfocus the text field to prevent keyboard issues + textFieldFocusNode.unfocus(); + + // clear chat + if (val == 'clear_chat') { + _showClearChatDialog(); + return; + } + + // enable apps - navigate back to home and show apps page + if (val == 'enable') { + _navigateToAppsPage(); + return; + } + + // select app by id + _selectApp(val, provider); + } + + void _showClearChatDialog() { + if (!mounted) return; + + showDialog( + context: context, + builder: (ctx) { + return getDialog(context, () { + Navigator.of(context).pop(); + }, () { + if (mounted) { + context.read().clearChat(); + Navigator.of(context).pop(); + } + }, "Clear Chat?", "Are you sure you want to clear the chat? This action cannot be undone."); + }, + ); + } + + void _navigateToAppsPage() { + if (!mounted) return; + + MixpanelManager().pageOpened('Chat Apps'); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => const HomePageWrapper(navigateToRoute: '/apps'), + ), + ); + } + + void _selectApp(String appId, AppProvider appProvider) async { + if (!mounted) return; + + // Mark that we're no longer on initial load to prevent auto-focus + _isInitialLoad = false; + + // Store references before async operation + final messageProvider = mounted ? context.read() : null; + if (messageProvider == null) return; + + // Set the selected app + appProvider.setSelectedChatAppId(appId); + + // Add a small delay to let the keyboard animation complete + // This prevents the widget from being unmounted during the keyboard transition + await Future.delayed(const Duration(milliseconds: 100)); + + // Check if widget is still mounted after delay + if (!mounted) return; + + // Perform async operation + await messageProvider.refreshMessages(dropdownSelected: true); + + // Check if widget is still mounted before proceeding + if (!mounted) return; + + // Get the selected app and send initial message if needed + var app = appProvider.getSelectedApp(); + if (messageProvider.messages.isEmpty) { + messageProvider.sendInitialAppMessage(app); + } + } + + PreferredSizeWidget _buildAppBar(BuildContext context, MessageProvider provider) { + return AppBar( + backgroundColor: Theme.of(context).colorScheme.surface, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: Colors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: Consumer( + builder: (context, appProvider, child) { + return _buildAppSelection(context, appProvider); + }, + ), + centerTitle: true, + actions: const [ + // IconButton( + // icon: const Icon(Icons.history, color: Colors.white), + // onPressed: () { + // HapticFeedback.mediumImpact(); + // // Dismiss keyboard before opening drawer + // FocusScope.of(context).unfocus(); + // scaffoldKey.currentState?.openEndDrawer(); + // }, + // ), + ], + bottom: provider.isLoadingMessages + ? PreferredSize( + preferredSize: const Size.fromHeight(32), + child: Container( + width: double.infinity, + height: 32, + color: Colors.green, + child: const Center( + child: Text( + 'Syncing messages with server...', + style: TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ), + ) + : null, + ); + } + + void _showAppsMenu(BuildContext ctx, AppProvider appProvider) { + final renderBox = _appButtonKey.currentContext?.findRenderObject() as RenderBox?; + if (renderBox == null) return; + + final overlay = Overlay.of(ctx, rootOverlay: true); + + final buttonOffset = renderBox.localToGlobal(Offset.zero); + final buttonSize = renderBox.size; + final screenSize = MediaQuery.of(ctx).size; + + const double menuWidth = 260; + const double maxMenuHeight = 250; + + double desiredTop = buttonOffset.dy + buttonSize.height + 8; + if ((desiredTop + maxMenuHeight) > screenSize.height) { + desiredTop = buttonOffset.dy - maxMenuHeight - 8; + if (desiredTop < 0) desiredTop = 8; + } + + late OverlayEntry entry; + + final controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 200), + ); + final curved = CurvedAnimation(parent: controller, curve: Curves.easeOut); + + entry = OverlayEntry( + builder: (context) { + return Consumer( + builder: (context, msgProvider, _) { + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + controller.reverse().then((_) => entry.remove()); + }, + child: Container(color: Colors.transparent), + ), + ), + Positioned( + left: buttonOffset.dx + (buttonSize.width - menuWidth) / 2, + top: desiredTop, + child: AnimatedBuilder( + animation: curved, + builder: (context, child) { + return Transform.scale( + scale: curved.value, + alignment: Alignment.topCenter, + child: Opacity( + opacity: curved.value, + child: child, + ), + ); + }, + child: Material( + color: Colors.grey[900], + borderRadius: BorderRadius.circular(12), + elevation: 8, + child: SizedBox( + width: menuWidth, + height: maxMenuHeight, + child: PullDownMenu( + items: [ + PullDownMenuItem( + title: 'Clear Chat', + iconWidget: const Icon(Icons.delete, color: Colors.redAccent, size: 16), + onTap: () { + controller.reverse().then((_) { + entry.remove(); + _handleAppSelection('clear_chat', appProvider); + }); + }, + ), + PullDownMenuItem( + title: 'Enable Apps', + iconWidget: const Icon(Icons.arrow_forward_ios, color: Colors.white60, size: 16), + onTap: () { + controller.reverse().then((_) { + entry.remove(); + _handleAppSelection('enable', appProvider); + }); + }, + ), + PullDownMenuItem( + title: 'Omi', + iconWidget: _getOmiAvatar(), + onTap: () { + controller.reverse().then((_) { + entry.remove(); + _handleAppSelection('no_selected', appProvider); + }); + }, + subtitle: + msgProvider.chatApps.firstWhereOrNull((a) => a.id == appProvider.selectedChatAppId) == + null + ? 'Selected' + : null, + ), + ...msgProvider.chatApps.map( + (app) => PullDownMenuItem( + title: app.getName(), + iconWidget: _getAppAvatar(app), + onTap: () { + controller.reverse().then((_) { + entry.remove(); + _handleAppSelection(app.id, appProvider); + }); + }, + subtitle: appProvider.selectedChatAppId == app.id ? 'Selected' : null, + ), + ) + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + }, + ); + + overlay.insert(entry); + controller.forward(); + } + + Widget _buildAppSelection(BuildContext context, AppProvider provider) { + final messageProvider = Provider.of(context, listen: false); + var selectedApp = messageProvider.chatApps.firstWhereOrNull((app) => app.id == provider.selectedChatAppId); + + return GestureDetector( + key: _appButtonKey, + onTap: () { + HapticFeedback.mediumImpact(); + _showAppsMenu(context, provider); + }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + selectedApp != null ? _getAppAvatar(selectedApp) : _getOmiAvatar(), + const SizedBox(width: 8), + Container( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + selectedApp != null ? selectedApp.getName() : "Omi", + style: const TextStyle(color: Colors.white, fontSize: 16), + overflow: TextOverflow.fade, + ), + ), + const SizedBox(width: 8), + const SizedBox( + width: 16, + child: Icon(Icons.keyboard_arrow_down, color: Colors.white60, size: 16), + ), + ], + ), + ); + } + + Widget _getAppAvatar(App app) { + return CachedNetworkImage( + imageUrl: app.getImageUrl(), + imageBuilder: (context, imageProvider) { + return CircleAvatar( + backgroundColor: Colors.white, + radius: 12, + backgroundImage: imageProvider, + ); + }, + errorWidget: (context, url, error) { + return const CircleAvatar( + backgroundColor: Colors.white, + radius: 12, + child: Icon(Icons.error_outline_rounded), + ); + }, + progressIndicatorBuilder: (context, url, progress) => CircleAvatar( + backgroundColor: Colors.white, + radius: 12, + child: CircularProgressIndicator( + value: progress.progress, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + ); + } + + Widget _getOmiAvatar() { + return Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(Assets.images.background.path), + fit: BoxFit.cover, + ), + borderRadius: const BorderRadius.all(Radius.circular(16.0)), + ), + height: 24, + width: 24, + child: Stack( + alignment: Alignment.center, + children: [ + Image.asset( + Assets.images.herologo.path, + height: 16, + width: 16, + ), + ], + ), + ); + } + void _showIOSStyleActionSheet(BuildContext context) { showModalBottomSheet( context: context, diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 5bbeb0ea0d..a8af2ddb3c 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -16,6 +16,7 @@ import 'package:omi/pages/apps/app_detail/app_detail.dart'; import 'package:omi/pages/apps/page.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/conversations/conversations_page.dart'; +import 'package:omi/pages/memories/page.dart'; import 'package:omi/pages/settings/data_privacy_page.dart'; import 'package:omi/pages/settings/settings_drawer.dart'; import 'package:omi/providers/app_provider.dart'; @@ -38,7 +39,6 @@ import 'package:omi/utils/enums.dart'; import 'package:omi/pages/conversation_capturing/page.dart'; import 'widgets/battery_info_widget.dart'; -import 'package:omi/widgets/app_selection_dropdown.dart'; class HomePageWrapper extends StatefulWidget { final String? navigateToRoute; @@ -89,7 +89,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker final GlobalKey> _conversationsPageKey = GlobalKey>(); final GlobalKey> _actionItemsPageKey = GlobalKey>(); - final GlobalKey> _chatPageKey = GlobalKey>(); + final GlobalKey> _memoriesPageKey = GlobalKey>(); final GlobalKey _appsPageKey = GlobalKey(); late final List _pages; @@ -113,6 +113,12 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } break; case 2: + final memoriesState = _memoriesPageKey.currentState; + if (memoriesState != null) { + (memoriesState as dynamic).scrollToTop(); + } + break; + case 3: final appsState = _appsPageKey.currentState; if (appsState != null) { appsState.scrollToTop(); @@ -148,7 +154,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ///Screens with respect to subpage final Map screensWithRespectToPath = { - // No additional screens needed + '/facts': const MemoriesPage(), }; bool? previousConnection; @@ -171,7 +177,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker _pages = [ ConversationsPage(key: _conversationsPageKey), ActionItemsPage(key: _actionItemsPageKey), - ChatPage(key: _chatPageKey), + MemoriesPage(key: _memoriesPageKey), AppsPage(key: _appsPageKey), ]; SharedPreferencesUtil().onboardingCompleted = true; @@ -194,12 +200,12 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } switch (pageAlias) { + case "memories": + homePageIdx = 2; + break; case "apps": homePageIdx = 3; break; - case "chat": - homePageIdx = 2; - break; } } @@ -260,6 +266,17 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker await Provider.of(context, listen: false).refreshMessages(); } } + // Navigate to chat page directly since it's no longer in the tab bar + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatPage(isPivotBottom: false), + ), + ); + } + }); break; case "settings": // Use context from the current widget instead of navigator key for bottom sheet @@ -277,6 +294,11 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } break; case "facts": + MyApp.navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => const MemoriesPage(), + ), + ); break; default: } @@ -414,7 +436,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker builder: (context, home, deviceProvider, child) { if (home.isChatFieldFocused || home.isConvoSearchFieldFocused || - home.isAppsSearchFieldFocused) { + home.isAppsSearchFieldFocused || + home.isMemoriesSearchFieldFocused) { return const SizedBox.shrink(); } else { // Check if OMI device is connected @@ -456,7 +479,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - home.selectedIndex == 0 ? FontAwesomeIcons.solidHouse : FontAwesomeIcons.house, + FontAwesomeIcons.house, color: home.selectedIndex == 0 ? Colors.white : Colors.grey, size: 24, ), @@ -497,14 +520,15 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), ), ), - // Center space for record button - only when no OMI device is connected and not on chat page - if (!isOmiDeviceConnected && home.selectedIndex != 2) const SizedBox(width: 80), - // Chat tab + // Center space for record button - only when no OMI device is connected + if (!isOmiDeviceConnected) const SizedBox(width: 80), + // Memories tab Expanded( child: InkWell( onTap: () { HapticFeedback.mediumImpact(); - MixpanelManager().bottomNavigationTabClicked('Chat'); + MixpanelManager().bottomNavigationTabClicked('Memories'); + primaryFocus?.unfocus(); if (home.selectedIndex == 2) { _scrollToTop(2); return; @@ -519,7 +543,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - FontAwesomeIcons.solidComment, + FontAwesomeIcons.brain, color: home.selectedIndex == 2 ? Colors.white : Colors.grey, size: 24, ), @@ -553,7 +577,6 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker FontAwesomeIcons.puzzlePiece, color: home.selectedIndex == 3 ? Colors.white : Colors.grey, size: 24, - weight: home.selectedIndex == 3 ? 900 : 400, ), ], ), @@ -565,8 +588,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), ), ), - // Central Record Button - Only show when no OMI device is connected and not on chat page - if (!isOmiDeviceConnected && home.selectedIndex != 2) + // Central Record Button - Only show when no OMI device is connected + if (!isOmiDeviceConnected) Positioned( left: MediaQuery.of(context).size.width / 2 - 40, bottom: 40, // Position it to protrude above the taller navbar (90px height) @@ -659,60 +682,118 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker return AppBar( automaticallyImplyLeading: false, backgroundColor: Theme.of(context).colorScheme.surface, - title: Stack( + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, children: [ + const BatteryInfoWidget(), + const SizedBox.shrink(), Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, children: [ - const BatteryInfoWidget(), - Row( - children: [ - Container( - width: 36, - height: 36, - decoration: const BoxDecoration( - color: Color(0xFF1F1F25), - shape: BoxShape.circle, - ), - child: IconButton( - padding: EdgeInsets.zero, - icon: const Icon( - FontAwesomeIcons.gear, - size: 16, - color: Colors.white70, - ), - onPressed: () { + Container( + width: 36, + height: 36, + decoration: const BoxDecoration( + color: Color(0xFF1F1F25), + shape: BoxShape.circle, + ), + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon( + FontAwesomeIcons.gear, + size: 16, + color: Colors.white70, + ), + onPressed: () { + HapticFeedback.mediumImpact(); + MixpanelManager().pageOpened('Settings'); + String language = SharedPreferencesUtil().userPrimaryLanguage; + bool hasSpeech = SharedPreferencesUtil().hasSpeakerProfile; + String transcriptModel = SharedPreferencesUtil().transcriptionModel; + SettingsDrawer.show(context); + if (language != SharedPreferencesUtil().userPrimaryLanguage || + hasSpeech != SharedPreferencesUtil().hasSpeakerProfile || + transcriptModel != SharedPreferencesUtil().transcriptionModel) { + if (context.mounted) { + context.read().onRecordProfileSettingChanged(); + } + } + }, + ), + ), + // Chat Button - Only show on home page (index 0) + Consumer( + builder: (context, provider, child) { + if (provider.selectedIndex == 0) { + return GestureDetector( + onTap: () { HapticFeedback.mediumImpact(); - MixpanelManager().pageOpened('Settings'); - String language = SharedPreferencesUtil().userPrimaryLanguage; - bool hasSpeech = SharedPreferencesUtil().hasSpeakerProfile; - String transcriptModel = SharedPreferencesUtil().transcriptionModel; - SettingsDrawer.show(context); - if (language != SharedPreferencesUtil().userPrimaryLanguage || - hasSpeech != SharedPreferencesUtil().hasSpeakerProfile || - transcriptModel != SharedPreferencesUtil().transcriptionModel) { - if (context.mounted) { - context.read().onRecordProfileSettingChanged(); - } - } + MixpanelManager().bottomNavigationTabClicked('Chat'); + // Navigate to chat page + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const ChatPage(isPivotBottom: false), + ), + ); }, - ), - ), - ], + child: Container( + height: 36, + margin: const EdgeInsets.only(left: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + colors: [ + Colors.deepPurpleAccent.withValues(alpha: 0.3), + Colors.purpleAccent.withValues(alpha: 0.2), + Colors.deepPurpleAccent.withValues(alpha: 0.3), + Colors.purpleAccent.withValues(alpha: 0.2), + Colors.deepPurpleAccent.withValues(alpha: 0.3), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Container( + margin: const EdgeInsets.all(0.5), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.deepPurpleAccent.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(17.5), + border: Border.all( + color: Colors.pink.withValues(alpha: 0.3), + width: 0.5, + ), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + FontAwesomeIcons.solidComment, + size: 14, + color: Colors.white70, + ), + SizedBox(width: 6), + Text( + 'Ask', + style: TextStyle( + color: Colors.white70, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } else { + return const SizedBox.shrink(); + } + }, ), ], ), - Consumer( - builder: (context, homeProvider, child) { - if (homeProvider.selectedIndex == 2) { // Chat page index - return const Center( - child: AppSelectionDropdown(isFloating: false), - ); - } - return const SizedBox.shrink(); - }, - ), ], ), elevation: 0, diff --git a/app/lib/pages/home/widgets/battery_info_widget.dart b/app/lib/pages/home/widgets/battery_info_widget.dart index bfd296daa1..945726e7b8 100644 --- a/app/lib/pages/home/widgets/battery_info_widget.dart +++ b/app/lib/pages/home/widgets/battery_info_widget.dart @@ -30,11 +30,9 @@ class BatteryInfoWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Selector( - selector: (context, state) => state.selectedIndex, - builder: (context, selectedIndex, child) { - final isConversationPage = selectedIndex == 0; - final isChatPage = selectedIndex == 2; + return Selector( + selector: (context, state) => state.selectedIndex == 0, + builder: (context, isMemoriesPage, child) { return Consumer( builder: (context, deviceProvider, child) { if (deviceProvider.connectedDevice != null) { @@ -126,11 +124,10 @@ class BatteryInfoWidget extends StatelessWidget { ), ), const SizedBox(width: 8.0), - if (!isChatPage) - Text( - "Disconnected", - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.white70), - ), + Text( + "Disconnected", + style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.white70), + ), ], ), ), @@ -158,13 +155,13 @@ class BatteryInfoWidget extends StatelessWidget { width: MediaQuery.sizeOf(context).width * 0.05, height: MediaQuery.sizeOf(context).width * 0.05, ), - isConversationPage ? const SizedBox(width: 8) : const SizedBox.shrink(), - deviceProvider.isConnecting && isConversationPage + isMemoriesPage ? const SizedBox(width: 8) : const SizedBox.shrink(), + deviceProvider.isConnecting && isMemoriesPage ? Text( "Searching", style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.white), ) - : isConversationPage + : isMemoriesPage ? Text( "Connect Device", style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Colors.white), diff --git a/app/lib/pages/memories/page.dart b/app/lib/pages/memories/page.dart index f7383fca95..553250c63a 100644 --- a/app/lib/pages/memories/page.dart +++ b/app/lib/pages/memories/page.dart @@ -57,7 +57,6 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien bool get wantKeepAlive => true; final TextEditingController _searchController = TextEditingController(); - final FocusNode _searchFocusNode = FocusNode(); MemoryCategory? _selectedCategory; final ScrollController _scrollController = ScrollController(); @@ -70,7 +69,6 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien @override void dispose() { _searchController.dispose(); - _searchFocusNode.dispose(); _scrollController.dispose(); _removeDeleteNotification(); super.dispose(); @@ -238,10 +236,6 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien return PopScope( canPop: true, child: Scaffold( - appBar: AppBar( - title: const Text('Memories'), - backgroundColor: Theme.of(context).colorScheme.primary, - ), backgroundColor: Theme.of(context).colorScheme.primary, body: RefreshIndicator( onRefresh: () async { @@ -339,7 +333,7 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien padding: WidgetStateProperty.all( const EdgeInsets.symmetric(horizontal: 12, vertical: 4), ), - focusNode: _searchFocusNode, + focusNode: home.memoriesSearchFieldFocusNode, controller: _searchController, trailing: provider.searchQuery.isNotEmpty ? [ @@ -579,7 +573,7 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien ) else SliverPadding( - padding: const EdgeInsets.only(top: 8, left: 16, right: 16, bottom: 80), + padding: const EdgeInsets.only(top: 8, left: 16, right: 16, bottom: 120), sliver: SliverList( delegate: SliverChildBuilderDelegate( (context, index) { @@ -622,7 +616,7 @@ class MemoriesPageState extends State with AutomaticKeepAliveClien Widget _buildShimmerMemoryList() { return Padding( - padding: const EdgeInsets.only(top: 8, left: 16, right: 16, bottom: 80), + padding: const EdgeInsets.only(top: 8, left: 16, right: 16, bottom: 120), child: ListView.builder( itemCount: 8, // Show 8 shimmer items itemBuilder: (context, index) { diff --git a/app/lib/pages/settings/settings_drawer.dart b/app/lib/pages/settings/settings_drawer.dart index 4dbd0a99d0..e86c3f1303 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -11,11 +11,9 @@ import 'package:omi/pages/settings/data_privacy_page.dart'; import 'package:omi/pages/settings/developer.dart'; import 'package:omi/pages/settings/profile.dart'; import 'package:omi/pages/settings/usage_page.dart'; -import 'package:omi/pages/memories/page.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/utils/other/temp.dart'; import 'package:omi/utils/platform/platform_service.dart'; -import 'package:omi/utils/analytics/mixpanel.dart'; import 'package:omi/widgets/dialog.dart'; import 'package:intercom_flutter/intercom_flutter.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -266,20 +264,6 @@ class _SettingsDrawerState extends State { }, ), const Divider(height: 1, color: Color(0xFF3C3C43)), - _buildSettingsItem( - title: 'Memories', - icon: const FaIcon(FontAwesomeIcons.brain, color: Color(0xFF8E8E93), size: 20), - onTap: () { - MixpanelManager().bottomNavigationTabClicked('Memories'); - Navigator.pop(context); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => const MemoriesPage(), - ), - ); - }, - ), - const Divider(height: 1, color: Color(0xFF3C3C43)), _buildSettingsItem( title: showSubscription ? 'Plan & Usage' : 'Usage Insights', icon: const FaIcon(FontAwesomeIcons.chartBar, color: Color(0xFF8E8E93), size: 20), diff --git a/app/lib/providers/home_provider.dart b/app/lib/providers/home_provider.dart index 02d69d8fb8..83c4d74272 100644 --- a/app/lib/providers/home_provider.dart +++ b/app/lib/providers/home_provider.dart @@ -12,9 +12,11 @@ class HomeProvider extends ChangeNotifier { final FocusNode chatFieldFocusNode = FocusNode(); final FocusNode appsSearchFieldFocusNode = FocusNode(); final FocusNode convoSearchFieldFocusNode = FocusNode(); + final FocusNode memoriesSearchFieldFocusNode = FocusNode(); bool isAppsSearchFieldFocused = false; bool isChatFieldFocused = false; bool isConvoSearchFieldFocused = false; + bool isMemoriesSearchFieldFocused = false; bool hasSpeakerProfile = true; bool isLoading = false; String userPrimaryLanguage = SharedPreferencesUtil().userPrimaryLanguage; @@ -67,28 +69,20 @@ class HomeProvider extends ChangeNotifier { chatFieldFocusNode.addListener(_onFocusChange); appsSearchFieldFocusNode.addListener(_onFocusChange); convoSearchFieldFocusNode.addListener(_onFocusChange); + memoriesSearchFieldFocusNode.addListener(_onFocusChange); } void _onFocusChange() { isChatFieldFocused = chatFieldFocusNode.hasFocus; isAppsSearchFieldFocused = appsSearchFieldFocusNode.hasFocus; isConvoSearchFieldFocused = convoSearchFieldFocusNode.hasFocus; + isMemoriesSearchFieldFocused = memoriesSearchFieldFocusNode.hasFocus; notifyListeners(); } void setIndex(int index) { selectedIndex = index; notifyListeners(); - - if (index == 2) { - WidgetsBinding.instance.addPostFrameCallback((_) { - Future.delayed(const Duration(milliseconds: 100), () { - if (chatFieldFocusNode.canRequestFocus && !chatFieldFocusNode.hasFocus) { - chatFieldFocusNode.requestFocus(); - } - }); - }); - } } void setIsLoading(bool loading) { @@ -187,6 +181,8 @@ class HomeProvider extends ChangeNotifier { chatFieldFocusNode.removeListener(_onFocusChange); appsSearchFieldFocusNode.removeListener(_onFocusChange); convoSearchFieldFocusNode.removeListener(_onFocusChange); + memoriesSearchFieldFocusNode.removeListener(_onFocusChange); + memoriesSearchFieldFocusNode.dispose(); chatFieldFocusNode.dispose(); appsSearchFieldFocusNode.dispose(); convoSearchFieldFocusNode.dispose(); diff --git a/app/lib/widgets/app_selection_dropdown.dart b/app/lib/widgets/app_selection_dropdown.dart deleted file mode 100644 index 3ea04303c4..0000000000 --- a/app/lib/widgets/app_selection_dropdown.dart +++ /dev/null @@ -1,373 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:pull_down_button/pull_down_button.dart'; -import 'package:omi/backend/schema/app.dart'; -import 'package:omi/gen/assets.gen.dart'; -import 'package:omi/pages/home/page.dart'; -import 'package:omi/providers/app_provider.dart'; -import 'package:omi/providers/message_provider.dart'; -import 'package:omi/utils/analytics/mixpanel.dart'; -import 'package:omi/widgets/dialog.dart'; -import 'package:provider/provider.dart'; - -typedef AppSelectionCallback = void Function(String? value, AppProvider provider); - -class AppSelectionDropdown extends StatefulWidget { - final AppSelectionCallback? onAppSelected; - final VoidCallback? onClearChat; - final VoidCallback? onEnableApps; - final bool isFloating; - - const AppSelectionDropdown({ - super.key, - this.onAppSelected, - this.onClearChat, - this.onEnableApps, - this.isFloating = true, - }); - - @override - State createState() => _AppSelectionDropdownState(); -} - -class _AppSelectionDropdownState extends State with TickerProviderStateMixin { - final GlobalKey _appButtonKey = GlobalKey(); - - void _handleAppSelection(String? val, AppProvider provider) { - if (val == null || val == provider.selectedChatAppId) { - return; - } - - // clear chat - if (val == 'clear_chat') { - _showClearChatDialog(); - return; - } - - // enable apps - navigate back to home and show apps page - if (val == 'enable') { - _navigateToAppsPage(); - return; - } - - // select app by id - _selectApp(val, provider); - } - - void _showClearChatDialog() { - if (!mounted) return; - - showDialog( - context: context, - builder: (ctx) { - return getDialog(context, () { - Navigator.of(context).pop(); - }, () { - if (mounted) { - context.read().clearChat(); - Navigator.of(context).pop(); - } - widget.onClearChat?.call(); - }, "Clear Chat?", "Are you sure you want to clear the chat? This action cannot be undone."); - }, - ); - } - - void _navigateToAppsPage() { - if (!mounted) return; - - MixpanelManager().pageOpened('Chat Apps'); - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (context) => const HomePageWrapper(navigateToRoute: '/apps'), - ), - ); - widget.onEnableApps?.call(); - } - - void _selectApp(String appId, AppProvider appProvider) async { - if (!mounted) return; - - // Store references before async operation - final messageProvider = mounted ? context.read() : null; - if (messageProvider == null) return; - - // Set the selected app - appProvider.setSelectedChatAppId(appId); - - // Add a small delay to let the keyboard animation complete - // This prevents the widget from being unmounted during the keyboard transition - await Future.delayed(const Duration(milliseconds: 100)); - - // Check if widget is still mounted after delay - if (!mounted) return; - - // Perform async operation - await messageProvider.refreshMessages(dropdownSelected: true); - - // Check if widget is still mounted before proceeding - if (!mounted) return; - - // Get the selected app and send initial message if needed - var app = appProvider.getSelectedApp(); - if (messageProvider.messages.isEmpty) { - messageProvider.sendInitialAppMessage(app); - } - - widget.onAppSelected?.call(appId, appProvider); - } - - void _showAppsMenu(BuildContext ctx, AppProvider provider) { - final renderBox = _appButtonKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox == null) return; - - final overlay = Overlay.of(ctx, rootOverlay: true); - - final buttonOffset = renderBox.localToGlobal(Offset.zero); - final buttonSize = renderBox.size; - final screenSize = MediaQuery.of(ctx).size; - - const double menuWidth = 260; - const double maxMenuHeight = 250; - - double desiredTop = buttonOffset.dy + buttonSize.height + 8; - if ((desiredTop + maxMenuHeight) > screenSize.height) { - desiredTop = buttonOffset.dy - maxMenuHeight - 8; - if (desiredTop < 0) desiredTop = 8; - } - - late OverlayEntry entry; - - final controller = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 200), - ); - final curved = CurvedAnimation(parent: controller, curve: Curves.easeOut); - - entry = OverlayEntry( - builder: (context) { - return Consumer( - builder: (context, msgProvider, _) { - return Stack( - children: [ - Positioned.fill( - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () { - controller.reverse().then((_) => entry.remove()); - }, - child: Container(color: Colors.transparent), - ), - ), - Positioned( - left: buttonOffset.dx + (buttonSize.width - menuWidth) / 2, - top: desiredTop, - child: AnimatedBuilder( - animation: curved, - builder: (context, child) { - return Transform.scale( - scale: curved.value, - alignment: Alignment.topCenter, - child: Opacity( - opacity: curved.value, - child: child, - ), - ); - }, - child: Material( - color: Colors.grey[900], - borderRadius: BorderRadius.circular(12), - elevation: 8, - child: SizedBox( - width: menuWidth, - height: maxMenuHeight, - child: PullDownMenu( - items: [ - PullDownMenuItem( - title: 'Clear Chat', - iconWidget: const Icon(Icons.delete, color: Colors.redAccent, size: 16), - onTap: () { - controller.reverse().then((_) { - entry.remove(); - _handleAppSelection('clear_chat', provider); - }); - }, - ), - PullDownMenuItem( - title: 'Enable Apps', - iconWidget: const Icon(Icons.arrow_forward_ios, color: Colors.white60, size: 16), - onTap: () { - controller.reverse().then((_) { - entry.remove(); - _handleAppSelection('enable', provider); - }); - }, - ), - PullDownMenuItem( - title: 'Omi', - iconWidget: _getOmiAvatar(), - onTap: () { - controller.reverse().then((_) { - entry.remove(); - _handleAppSelection('no_selected', provider); - }); - }, - subtitle: - msgProvider.chatApps.firstWhereOrNull((a) => a.id == provider.selectedChatAppId) == - null - ? 'Selected' - : null, - ), - ...msgProvider.chatApps.map( - (app) => PullDownMenuItem( - title: app.getName(), - iconWidget: _getAppAvatar(app), - onTap: () { - controller.reverse().then((_) { - entry.remove(); - _handleAppSelection(app.id, provider); - }); - }, - subtitle: provider.selectedChatAppId == app.id ? 'Selected' : null, - ), - ) - ], - ), - ), - ), - ), - ), - ], - ); - }, - ); - }, - ); - - overlay.insert(entry); - controller.forward(); - } - - Widget _buildAppSelection(BuildContext context, AppProvider provider) { - final messageProvider = Provider.of(context, listen: false); - var selectedApp = messageProvider.chatApps.firstWhereOrNull((app) => app.id == provider.selectedChatAppId); - - return GestureDetector( - key: _appButtonKey, - onTap: () { - HapticFeedback.mediumImpact(); - _showAppsMenu(context, provider); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: widget.isFloating - ? BoxDecoration( - color: Colors.black.withOpacity(0.7), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.white.withOpacity(0.15), - width: 1, - ), - ) - : BoxDecoration( - color: Colors.black.withOpacity(0.6), - borderRadius: BorderRadius.circular(30), - border: Border.all( - color: Colors.white.withOpacity(0.2), - width: 1, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - selectedApp != null ? _getAppAvatar(selectedApp) : _getOmiAvatar(), - const SizedBox(width: 10), - Container( - constraints: const BoxConstraints(maxWidth: 100), - child: Text( - selectedApp != null ? selectedApp.getName() : "Omi", - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - const Icon( - Icons.keyboard_arrow_down, - color: Colors.white70, - size: 18, - ), - ], - ), - ), - ); - } - - Widget _getAppAvatar(App app) { - return CachedNetworkImage( - imageUrl: app.getImageUrl(), - imageBuilder: (context, imageProvider) { - return CircleAvatar( - backgroundColor: Colors.white, - radius: 12, - backgroundImage: imageProvider, - ); - }, - errorWidget: (context, url, error) { - return const CircleAvatar( - backgroundColor: Colors.white, - radius: 12, - child: Icon(Icons.error_outline_rounded), - ); - }, - progressIndicatorBuilder: (context, url, progress) => CircleAvatar( - backgroundColor: Colors.white, - radius: 12, - child: CircularProgressIndicator( - value: progress.progress, - valueColor: const AlwaysStoppedAnimation(Colors.white), - ), - ), - ); - } - - Widget _getOmiAvatar() { - return Container( - decoration: BoxDecoration( - image: DecorationImage( - image: AssetImage(Assets.images.background.path), - fit: BoxFit.cover, - ), - borderRadius: const BorderRadius.all(Radius.circular(16.0)), - ), - height: 24, - width: 24, - child: Stack( - alignment: Alignment.center, - children: [ - Image.asset( - Assets.images.herologo.path, - height: 16, - width: 16, - ), - ], - ), - ); - } - - @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, appProvider, child) { - return _buildAppSelection(context, appProvider); - }, - ); - } -}