diff --git a/.github/workflows/mobile-app-checks.yml b/.github/workflows/mobile-app-checks.yml index 2cac7a0e888..67f76721bc4 100644 --- a/.github/workflows/mobile-app-checks.yml +++ b/.github/workflows/mobile-app-checks.yml @@ -126,8 +126,8 @@ jobs: - name: Prepare Flutter dev compile inputs working-directory: app run: | - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_dev.dart - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_prod.dart + cp lib/firebase_options_local.dart lib/firebase_options_dev.dart + cp lib/firebase_options_local.dart lib/firebase_options_prod.dart { echo "API_BASE_URL=" echo "USE_WEB_AUTH=true" @@ -262,8 +262,8 @@ jobs: working-directory: app run: | mkdir -p android/app/src/dev/ android/app/src/prod/ - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_dev.dart - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_prod.dart + cp lib/firebase_options_local.dart lib/firebase_options_dev.dart + cp lib/firebase_options_local.dart lib/firebase_options_prod.dart cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json cp setup/prebuilt/key.properties android/key.properties diff --git a/.gitignore b/.gitignore index 5cb3d9d96fb..ed030823669 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,6 @@ src/recordings /app/lib/firebase_options.dart /app/lib/firebase_options_*.dart !/app/lib/firebase_options_local.dart -/app/ios/Runner/Info-Dev.plist /app/firebase.json # Solidworks models diff --git a/app/ios/Flutter/prodDebug.xcconfig b/app/ios/Flutter/prodDebug.xcconfig index a76743ea9c2..d6f69eecfbc 100644 --- a/app/ios/Flutter/prodDebug.xcconfig +++ b/app/ios/Flutter/prodDebug.xcconfig @@ -2,6 +2,9 @@ #include "Generated.xcconfig" #include "Base.xcconfig" #include "Custom.xcconfig" +// Keep production Google sign-in deterministic even when Custom.xcconfig is +// regenerated locally for a beta or developer build. +GOOGLE_REVERSE_CLIENT_ID=com.googleusercontent.apps.1031333818730-dusn243nct6i5rgfpfkj5mchuj1qnmde ASSET_PREFIX=prod BUNDLE_NAME=Omi diff --git a/app/ios/Flutter/prodProfile.xcconfig b/app/ios/Flutter/prodProfile.xcconfig index 3024241ef2e..1d10004d943 100644 --- a/app/ios/Flutter/prodProfile.xcconfig +++ b/app/ios/Flutter/prodProfile.xcconfig @@ -2,6 +2,9 @@ #include "Generated.xcconfig" #include "Base.xcconfig" #include "Custom.xcconfig" +// Keep production Google sign-in deterministic even when Custom.xcconfig is +// regenerated locally for a beta or developer build. +GOOGLE_REVERSE_CLIENT_ID=com.googleusercontent.apps.1031333818730-dusn243nct6i5rgfpfkj5mchuj1qnmde ASSET_PREFIX=prod BUNDLE_NAME=Omi diff --git a/app/ios/Flutter/prodRelease.xcconfig b/app/ios/Flutter/prodRelease.xcconfig index 3024241ef2e..3b7160c75c1 100644 --- a/app/ios/Flutter/prodRelease.xcconfig +++ b/app/ios/Flutter/prodRelease.xcconfig @@ -2,6 +2,9 @@ #include "Generated.xcconfig" #include "Base.xcconfig" #include "Custom.xcconfig" +// Keep production Google sign-in deterministic even when Custom.xcconfig is +// regenerated locally for a beta or developer build. +GOOGLE_REVERSE_CLIENT_ID=com.googleusercontent.apps.208440318997-ukinsq3sijhcetkhr26ssqp1terbq7as ASSET_PREFIX=prod BUNDLE_NAME=Omi diff --git a/app/ios/Runner/Info-Dev.plist b/app/ios/Runner/Info-Dev.plist new file mode 100644 index 00000000000..d88513e469c --- /dev/null +++ b/app/ios/Runner/Info-Dev.plist @@ -0,0 +1,216 @@ + + + + + BGTaskSchedulerPermittedIdentifiers + + daily-summary + com.friend-app-with-wearable.ios12.daily-summary + dev.flutter.background.refresh + com.pravera.flutter_foreground_task.refresh + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + $(BUNDLE_DISPLAY_NAME) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleLocalizations + + ar + bg + ca + cs + da + de + el + en + es + et + fi + fr + hi + hu + id + it + ja + ko + lt + lv + ms + nl + no + pl + pt + ro + ru + sk + sv + th + tr + uk + vi + zh + + CFBundleName + $(BUNDLE_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + h.omi.me + CFBundleURLSchemes + + https + + + + CFBundleTypeRole + Editor + CFBundleURLName + h.omi.me + CFBundleURLSchemes + + $(GOOGLE_REVERSE_CLIENT_ID) + + + + CFBundleTypeRole + Editor + CFBundleURLName + omi.auth + CFBundleURLSchemes + + $(AUTH_CALLBACK_SCHEME) + + + + CFBundleTypeRole + Editor + CFBundleURLName + rayban-meta-callback + CFBundleURLSchemes + + omirayban + + + + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + FlutterDeepLinkingEnabled + + ITSAppUsesNonExemptEncryption + + LSApplicationQueriesSchemes + + comgooglemaps + iosamap + + LSRequiresIPhoneOS + + MWDAT + + AppLinkURLScheme + omirayban:// + DAMEnabled + + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + NSBluetoothAlwaysUsageDescription + Omi uses Bluetooth to connect to your Omi wearable and stream its microphone audio to your phone — for example, to transcribe your meetings into notes and check the device's battery. + NSBluetoothPeripheralUsageDescription + Omi uses Bluetooth to connect to your Omi wearable and stream its microphone audio to your phone — for example, to transcribe your meetings into notes and check the device's battery. + NSCalendarsFullAccessUsageDescription + Access most functions for calendar viewing and editing. + NSCalendarsUsageDescription + Access most functions for calendar viewing and editing. + NSCameraUsageDescription + Camera access is required to report issues + NSContactsUsageDescription + Access contacts for event attendee editing and to share conversation summaries via SMS. + NSHealthShareUsageDescription + Omi reads your steps, walking and running distance, active energy, heart rate, sleep, and workouts from Apple Health so you can ask about your fitness, sleep, and activity in Omi Chat. Omi never writes to Apple Health. + NSHealthUpdateUsageDescription + Omi does not write any data to Apple Health. This string is only present because iOS requires it when the HealthKit framework is linked. + NSLocationAlwaysAndWhenInUseUsageDescription + When creating your memories, we use your location for determining where they were created. + NSLocationUsageDescription + When creating your memories, we use your location for determining where they were created. + NSLocationWhenInUseUsageDescription + When creating your memories, we use your location for determining where they were created. + NSMicrophoneUsageDescription + We need access to your microphone so you can share audio explanations of Bugs you find, and for voice commands. + NSPhotoLibraryUsageDescription + We need access to your photo library to allow you to upload and share photos through Instabug. + NSRemindersFullAccessUsageDescription + We need access to your reminders to export action items from Omi conversations. + NSRemindersUsageDescription + We need access to your reminders to export action items from Omi conversations. + NSSpeechRecognitionUsageDescription + We use speech recognition to convert your speech to text for processing on your device. + PermissionGroupNotification + You need to enable notifications to receive your pro-active feedback. + UIApplicationSupportsIndirectInputEvents + + UIBackgroundModes + + audio + location + bluetooth-central + fetch + processing + remote-notification + voip + + UIDeviceFamily + + 1 + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiresFullScreen + + UIStatusBarHidden + + UISupportedExternalAccessoryProtocols + + com.meta.ar.wearable + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + + UIViewControllerBasedStatusBarAppearance + + WKCompanionAppBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + com.posthog.posthog.AUTO_INIT + + + diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index 442ad934aa9..0b537426856 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -15,6 +15,7 @@ import 'package:omi/services/app_review_service.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/other/debouncer.dart'; import 'widgets/action_item_form_sheet.dart'; +import 'widgets/action_item_shimmer_widget.dart'; // Re-export Goal from goals.dart for use in this file export 'package:omi/backend/http/api/goals.dart' show Goal; @@ -589,7 +590,15 @@ class _ActionItemsPageState extends State with AutomaticKeepAli } Widget _buildLoadingState() { - return const Center(child: CircularProgressIndicator(color: Colors.deepPurple)); + return CustomScrollView( + controller: _scrollController, + physics: const NeverScrollableScrollPhysics(), + slivers: const [ + SliverPadding(padding: EdgeInsets.only(top: 16)), + ActionItemsShimmerList(itemCount: 7), + SliverPadding(padding: EdgeInsets.only(bottom: 100)), + ], + ); } Widget _buildEmptyTasksList() { diff --git a/app/lib/pages/apps/page.dart b/app/lib/pages/apps/page.dart index 19aabeb053a..4540c21979d 100644 --- a/app/lib/pages/apps/page.dart +++ b/app/lib/pages/apps/page.dart @@ -1,11 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:omi/backend/schema/app.dart'; import 'package:omi/pages/apps/explore_install_page.dart'; -import 'package:omi/pages/apps/providers/add_app_provider.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/utils/logger.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -23,10 +25,22 @@ class AppsPageState extends State with AutomaticKeepAliveClientMixin { @override void initState() { + super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - context.read().getCategories(); + if (mounted) unawaited(_loadApps()); }); - super.initState(); + } + + Future _loadApps() async { + try { + final appProvider = context.read(); + await appProvider.getApps(); + if (mounted) { + await appProvider.getPopularApps(); + } + } catch (e, s) { + Logger.handle(e, s, message: 'Error loading apps page data'); + } } void scrollToTop() { diff --git a/app/lib/pages/conversation_detail/page.dart b/app/lib/pages/conversation_detail/page.dart index 1b7733e9d39..bde986d3feb 100644 --- a/app/lib/pages/conversation_detail/page.dart +++ b/app/lib/pages/conversation_detail/page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -199,8 +201,6 @@ class _ConversationDetailPageState extends State with Ti provider.setCachedConversation(widget.conversation); _providerInitialized = true; - conversationProvider.groupConversationsByDate(); - // Find the proper date and index for this conversation in the grouped conversations final result = conversationProvider.getConversationDateAndIndex(widget.conversation); if (result != null) { @@ -213,12 +213,22 @@ class _ConversationDetailPageState extends State with Ti await provider.initConversation(); if (provider.conversation.appResults.isEmpty) { - final date = provider.selectedDate; - final idx = conversationProvider.getConversationIndexById(provider.conversation.id, date); - if (idx != -1) { - await conversationProvider.updateSearchedConvoDetails(provider.conversation.id, date, idx); + final conversationId = provider.conversation.id; + if (conversationProvider.getConversationDateAndIndexById(conversationId) != null) { + // The initial list payload is enough to render the detail page. Fill + // in omitted app results after the first usable frame instead of + // holding the destination's startup sequence on this request. The + // provider re-locates the conversation by ID after the await because + // refreshes can reorder or replace the grouped list meanwhile. + unawaited( + conversationProvider.updateSearchedConvoDetails(conversationId).then((_) { + if (!mounted || provider.conversationOrNull?.id != conversationId) return; + provider.updateConversation(conversationId, provider.selectedDate); + }), + ); + } else { + provider.updateConversation(provider.conversation.id, provider.selectedDate); } - provider.updateConversation(provider.conversation.id, provider.selectedDate); } // Check if this is the first conversation and show app review prompt @@ -1435,13 +1445,12 @@ class _SummaryTabState extends State with AutomaticKeepAliveClientMi builder: (context, data, child) { return Stack( children: [ - ListView( - shrinkWrap: true, + CustomScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.manual, - children: [ - const GetSummaryWidgets(), + slivers: [ + const SliverToBoxAdapter(child: GetSummaryWidgets()), data.item1 - ? const ReprocessDiscardedWidget() + ? const SliverToBoxAdapter(child: ReprocessDiscardedWidget()) : GetAppsWidgets( searchQuery: widget.searchQuery, currentResultIndex: widget.currentResultIndex, @@ -1460,8 +1469,8 @@ class _SummaryTabState extends State with AutomaticKeepAliveClientMi context.read().saveEditingSummary(appId, newContent); }, ), - const GetGeolocationWidgets(), - const SizedBox(height: 150), + const SliverToBoxAdapter(child: GetGeolocationWidgets()), + const SliverToBoxAdapter(child: SizedBox(height: 150)), ], ), ], diff --git a/app/lib/pages/conversation_detail/widgets.dart b/app/lib/pages/conversation_detail/widgets.dart index ddae7cdc3d4..6c014e33469 100644 --- a/app/lib/pages/conversation_detail/widgets.dart +++ b/app/lib/pages/conversation_detail/widgets.dart @@ -786,6 +786,7 @@ class AppResultDetailWidget extends StatefulWidget { final VoidCallback? onEditStarted; final VoidCallback? onEditCancelled; final bool Function()? canStartEditing; + final bool asSliver; const AppResultDetailWidget({ super.key, @@ -798,6 +799,7 @@ class AppResultDetailWidget extends StatefulWidget { this.onEditStarted, this.onEditCancelled, this.canStartEditing, + this.asSliver = false, }); @override @@ -857,6 +859,10 @@ class _AppResultDetailWidgetState extends State { Widget build(BuildContext context) { final String content = widget.appResponse.content.trim().decodeString; + if (widget.asSliver) { + return _buildSliver(context, content); + } + return Container( margin: const EdgeInsets.only(bottom: 20), child: Column( @@ -988,8 +994,8 @@ class _AppResultDetailWidgetState extends State { ), ), const SizedBox( - child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 20), width: 42, + child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 20), ), ], ), @@ -1073,31 +1079,28 @@ class GetAppsWidgets extends StatelessWidget { return Consumer( builder: (context, provider, child) { final summarizedApp = provider.getSummarizedApp(); - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: summarizedApp == null ? CrossAxisAlignment.center : CrossAxisAlignment.start, - children: summarizedApp == null - ? [child!] - : [ - // Show the summarized app - if (!provider.conversation.discarded) ...[ - AppResultDetailWidget( - appResponse: summarizedApp, - app: provider.findAppById(summarizedApp.appId), - conversation: provider.conversation, - searchQuery: searchQuery, - currentResultIndex: currentResultIndex, - canStartEditing: canStartEditing, - onEditStarted: onEditStarted == null ? null : () => onEditStarted!(summarizedApp.appId), - onEditCancelled: onEditCancelled == null ? null : () => onEditCancelled!(summarizedApp.appId), - onSaveSummary: onSaveSummary == null - ? null - : (newContent) => onSaveSummary!(summarizedApp.appId, newContent), - ), - ], - const SizedBox(height: 8), - ], + if (summarizedApp == null) { + return SliverToBoxAdapter(child: child!); + } + + return SliverMainAxisGroup( + slivers: [ + if (!provider.conversation.discarded) + AppResultDetailWidget( + appResponse: summarizedApp, + app: provider.findAppById(summarizedApp.appId), + conversation: provider.conversation, + searchQuery: searchQuery, + currentResultIndex: currentResultIndex, + canStartEditing: canStartEditing, + onEditStarted: onEditStarted == null ? null : () => onEditStarted!(summarizedApp.appId), + onEditCancelled: onEditCancelled == null ? null : () => onEditCancelled!(summarizedApp.appId), + onSaveSummary: + onSaveSummary == null ? null : (newContent) => onSaveSummary!(summarizedApp.appId, newContent), + asSliver: true, + ), + const SliverToBoxAdapter(child: SizedBox(height: 8)), + ], ); }, child: ListView( @@ -1282,6 +1285,157 @@ class GetGeolocationWidgets extends StatelessWidget { } } +extension _AppResultDetailWidgetSliver on _AppResultDetailWidgetState { + Widget _buildSliver(BuildContext context, String content) { + if (content.isEmpty || _isEditing) { + return SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: content.isEmpty + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: GestureDetector( + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) => const SummarizedAppsBottomSheet(), + ); + }, + child: RichText( + text: TextSpan( + style: const TextStyle(color: Colors.grey), + text: context.l10n.noSummaryForApp, + ), + ), + ), + ), + ], + ) + : _buildEditor(context, content), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: 20)), + ], + ); + } + + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.only(bottom: 20), + sliver: ConversationMarkdownSliver( + content: content, + searchQuery: widget.searchQuery, + currentResultIndex: widget.currentResultIndex, + onDoubleTap: widget.onSaveSummary == null ? null : () => _startEditing(content), + ), + ), + SliverToBoxAdapter(child: _buildAppAttribution(context)), + ], + ); + } + + Widget _buildAppAttribution(BuildContext context) { + return GestureDetector( + onTap: () async { + if (widget.app != null) { + PlatformManager.instance.analytics.pageOpened('App Detail'); + await routeToPage(context, AppDetailPage(app: widget.app!)); + } + }, + child: Padding( + padding: const EdgeInsets.only(top: 12, left: 4), + child: Row( + children: [ + widget.app != null + ? CachedNetworkImage( + imageUrl: widget.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, size: 12), + ); + }, + progressIndicatorBuilder: (context, url, progress) => CircleAvatar( + backgroundColor: Colors.white, + radius: 12, + child: CircularProgressIndicator( + value: progress.progress, + valueColor: const AlwaysStoppedAnimation(Colors.white), + strokeWidth: 2, + ), + ), + ) + : Container( + decoration: BoxDecoration( + image: DecorationImage( + image: AssetImage(Assets.images.background.path), + fit: BoxFit.cover, + ), + borderRadius: const BorderRadius.all(Radius.circular(12.0)), + ), + height: 24, + width: 24, + child: Stack( + alignment: Alignment.center, + children: [Image.asset(Assets.images.herologo.path, height: 16, width: 16)], + ), + ), + const SizedBox(width: 8), + Expanded( + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.app != null ? widget.app!.name.decodeString : context.l10n.unknownApp, + maxLines: 1, + style: const TextStyle( + fontWeight: FontWeight.w500, + color: Colors.white, + fontSize: 14, + ), + ), + if (widget.app != null) + Text( + widget.app!.description.decodeString, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + const SizedBox( + width: 42, + child: Icon(Icons.arrow_forward_ios, color: Colors.white, size: 20), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + ///************************************************ ///************ SETTINGS BOTTOM SHEET ************* ///************************************************ diff --git a/app/lib/pages/conversation_detail/widgets/conversation_markdown_widget.dart b/app/lib/pages/conversation_detail/widgets/conversation_markdown_widget.dart index f15c571c018..f9713a9f493 100644 --- a/app/lib/pages/conversation_detail/widgets/conversation_markdown_widget.dart +++ b/app/lib/pages/conversation_detail/widgets/conversation_markdown_widget.dart @@ -3,6 +3,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:markdown/markdown.dart' as md; +MarkdownStyleSheet _conversationMarkdownStyle(BuildContext context) { + const style = TextStyle(color: Colors.white, fontSize: 16, height: 1.5); + + return MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( + a: style, + p: style.copyWith(height: 1.5), + pPadding: const EdgeInsets.only(bottom: 12), + blockquote: style.copyWith(backgroundColor: Colors.transparent, color: Colors.white), + blockquoteDecoration: BoxDecoration(color: const Color(0xFF35343B), borderRadius: BorderRadius.circular(4)), + code: style.copyWith( + backgroundColor: Colors.transparent, + decoration: TextDecoration.none, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + strong: style.copyWith(fontWeight: FontWeight.bold), + ); +} + class ConversationMarkdownWidget extends StatefulWidget { final String content; final String searchQuery; @@ -200,8 +219,6 @@ class _ConversationMarkdownWidgetState extends State String searchQuery = '', int currentResultIndex = -1, }) { - var style = const TextStyle(color: Colors.white, fontSize: 16, height: 1.5); - String processedContent = content; // If there's a search query, inject highlight tags @@ -214,20 +231,7 @@ class _ConversationMarkdownWidgetState extends State shrinkWrap: true, builders: searchQuery.isNotEmpty ? {'highlight': _SearchHighlightBuilder()} : {}, inlineSyntaxes: searchQuery.isNotEmpty ? [_SearchHighlightSyntax()] : [], - styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)).copyWith( - a: style, - p: style.copyWith(height: 1.5), - pPadding: const EdgeInsets.only(bottom: 12), - blockquote: style.copyWith(backgroundColor: Colors.transparent, color: Colors.white), - blockquoteDecoration: BoxDecoration(color: const Color(0xFF35343B), borderRadius: BorderRadius.circular(4)), - code: style.copyWith( - backgroundColor: Colors.transparent, - decoration: TextDecoration.none, - color: Colors.white, - fontWeight: FontWeight.w500, - ), - strong: style.copyWith(fontWeight: FontWeight.bold), - ), + styleSheet: _conversationMarkdownStyle(context), data: processedContent, ); } @@ -266,6 +270,218 @@ class _ConversationMarkdownWidgetState extends State } } +/// A sliver-backed Markdown renderer for the conversation summary. +/// +/// [MarkdownBody] eagerly lays out every parsed block when it is placed inside +/// the summary's shrink-wrapped scroll view. The normal path uses the +/// flutter_markdown AST widgets directly in a [SliverList], so only blocks in +/// or near the viewport participate in layout and paint. Search keeps the +/// paragraph-level structure used by the existing highlight/scroll behavior. +class ConversationMarkdownSliver extends StatefulWidget { + final String content; + final String searchQuery; + final int currentResultIndex; + final VoidCallback? onDoubleTap; + + const ConversationMarkdownSliver({ + super.key, + required this.content, + this.searchQuery = '', + this.currentResultIndex = -1, + this.onDoubleTap, + }); + + @override + State createState() => _ConversationMarkdownSliverState(); +} + +class _ConversationMarkdownSliverState extends State { + final List _paragraphKeys = []; + List _paragraphs = []; + int _previousSearchResultIndex = -1; + + @override + void initState() { + super.initState(); + _initializeParagraphs(); + } + + @override + void didUpdateWidget(ConversationMarkdownSliver oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.content != oldWidget.content) { + _initializeParagraphs(); + } + + if (widget.currentResultIndex != _previousSearchResultIndex && + widget.currentResultIndex >= 0 && + widget.searchQuery.isNotEmpty) { + _previousSearchResultIndex = widget.currentResultIndex; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSearchResult(); + }); + } + } + + void _initializeParagraphs() { + _paragraphs = widget.content.split('\n').where((p) => p.trim().isNotEmpty).toList(); + _paragraphKeys + ..clear() + ..addAll(List.generate(_paragraphs.length, (index) => GlobalKey())); + } + + @override + Widget build(BuildContext context) { + if (widget.content.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + if (widget.searchQuery.isNotEmpty) { + return SliverList.builder( + itemCount: _paragraphs.length, + itemBuilder: (context, index) { + final paragraph = _paragraphs[index]; + return Container( + key: index < _paragraphKeys.length ? _paragraphKeys[index] : null, + margin: const EdgeInsets.only(bottom: 8), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: widget.onDoubleTap, + child: _buildMarkdownBody( + context, + _highlightSearchInMarkdown(paragraph, widget.searchQuery, _getLocalSearchIndex(index)), + searchEnabled: true, + ), + ), + ); + }, + ); + } + + return _LazyConversationMarkdown( + data: widget.content, + styleSheet: _conversationMarkdownStyle(context), + onDoubleTap: widget.onDoubleTap, + ); + } + + Widget _buildMarkdownBody(BuildContext context, String content, {required bool searchEnabled}) { + return MarkdownBody( + selectable: false, + shrinkWrap: true, + builders: searchEnabled ? {'highlight': _SearchHighlightBuilder()} : {}, + inlineSyntaxes: searchEnabled ? [_SearchHighlightSyntax()] : [], + styleSheet: _conversationMarkdownStyle(context), + data: content, + ); + } + + int _getLocalSearchIndex(int paragraphIndex) { + if (widget.searchQuery.isEmpty || widget.currentResultIndex < 0) return -1; + + int currentMatchCount = 0; + final searchQuery = widget.searchQuery.toLowerCase(); + + for (int i = 0; i < paragraphIndex; i++) { + currentMatchCount += _countMatches(_paragraphs[i].toLowerCase(), searchQuery); + } + + final paragraphMatches = _countMatches(_paragraphs[paragraphIndex].toLowerCase(), searchQuery); + if (widget.currentResultIndex >= currentMatchCount && + widget.currentResultIndex < currentMatchCount + paragraphMatches) { + return widget.currentResultIndex - currentMatchCount; + } + + return -1; + } + + int _countMatches(String text, String query) { + if (query.isEmpty) return 0; + var count = 0; + var start = 0; + while (true) { + final index = text.indexOf(query, start); + if (index == -1) return count; + count++; + start = index + query.length; + } + } + + void _scrollToSearchResult() { + final targetParagraphIndex = _findParagraphForSearchResult(); + if (targetParagraphIndex >= 0 && targetParagraphIndex < _paragraphKeys.length) { + final targetContext = _paragraphKeys[targetParagraphIndex].currentContext; + if (targetContext != null) { + Scrollable.ensureVisible( + targetContext, + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + alignment: 0.40, + ); + } + } + } + + int _findParagraphForSearchResult() { + if (widget.searchQuery.isEmpty || widget.currentResultIndex < 0) return -1; + + var currentMatchCount = 0; + final searchQuery = widget.searchQuery.toLowerCase(); + for (int i = 0; i < _paragraphs.length; i++) { + final paragraphMatches = _countMatches(_paragraphs[i].toLowerCase(), searchQuery); + if (widget.currentResultIndex < currentMatchCount + paragraphMatches) return i; + currentMatchCount += paragraphMatches; + } + return -1; + } + + String _highlightSearchInMarkdown(String content, String searchQuery, int currentResultIndex) { + if (searchQuery.isEmpty) return content; + + final matches = RegExp(RegExp.escape(searchQuery), caseSensitive: false).allMatches(content); + if (matches.isEmpty) return content; + + var result = content; + var offset = 0; + var matchIndex = 0; + for (final match in matches) { + final isCurrentMatch = matchIndex == currentResultIndex; + final openTag = isCurrentMatch ? '{{H current}}' : '{{H}}'; + const closeTag = '{{/H}}'; + final start = match.start + offset; + final end = match.end + offset; + result = result.substring(0, start) + openTag + result.substring(start, end) + closeTag + result.substring(end); + offset += openTag.length + closeTag.length; + matchIndex++; + } + return result; + } +} + +class _LazyConversationMarkdown extends Markdown { + final VoidCallback? onDoubleTap; + + const _LazyConversationMarkdown({required super.data, required super.styleSheet, this.onDoubleTap}); + + @override + Widget build(BuildContext context, List? children) { + final markdownChildren = children ?? const []; + if (markdownChildren.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + + return SliverList.builder( + itemCount: markdownChildren.length, + itemBuilder: (context, index) => GestureDetector( + behavior: HitTestBehavior.translucent, + onDoubleTap: onDoubleTap, + child: markdownChildren[index], + ), + ); + } +} + class _SearchHighlightSyntax extends md.InlineSyntax { _SearchHighlightSyntax() : super(r'(\{\{H(?: current)?\}\})(.*?)(\{\{/H\}\})'); diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index 149539695c3..7cb589b47f2 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -26,7 +28,147 @@ import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/logger.dart'; import 'package:omi/utils/ui_guidelines.dart'; import 'widgets/conversations_group_widget.dart'; +import 'widgets/conversation_list_item.dart'; +import 'widgets/date_list_item.dart'; import 'widgets/empty_conversations.dart'; +import 'widgets/recording_list_item.dart'; + +enum _ConversationListRowKind { topSpacer, dateHeader, conversation, recording, groupSpacer } + +typedef _ConversationListRow = ({ + _ConversationListRowKind kind, + DateTime date, + bool isFirst, + ServerConversation? conversation, + LocalRecording? recording, + int conversationIndex, +}); + +typedef _ConversationPageSnapshot = ({ + List conversations, + Map> groupedConversations, + List processingConversations, + List recordings, + String previousQuery, + String? selectedFolderId, + String? selectedSpeakerId, + DateTime? selectedDate, + bool showStarredOnly, + bool showDailySummaries, + bool hasDailySummaries, + bool isSelectionModeActive, + bool isLoadingConversations, + bool isFetchingConversations, + bool isAwaitingInitialFetchRetry, + int conversationIdentitySignature, + int processingIdentitySignature, + int recordingIdentitySignature, + int pendingDeleteCount, +}); + +int _identitySignature(Iterable values) => Object.hashAll(values.map(identityHashCode)); + +_ConversationPageSnapshot _conversationPageSnapshot( + ConversationProvider conversations, + LocalRecordingsProvider recordings, +) { + return ( + conversations: conversations.conversations, + groupedConversations: conversations.groupedConversations, + processingConversations: conversations.processingConversations, + recordings: recordings.recordings, + previousQuery: conversations.previousQuery, + selectedFolderId: conversations.selectedFolderId, + selectedSpeakerId: conversations.selectedSpeakerId, + selectedDate: conversations.selectedDate, + showStarredOnly: conversations.showStarredOnly, + showDailySummaries: conversations.showDailySummaries, + hasDailySummaries: conversations.hasDailySummaries, + isSelectionModeActive: conversations.isSelectionModeActive, + isLoadingConversations: conversations.isLoadingConversations, + isFetchingConversations: conversations.isFetchingConversations, + isAwaitingInitialFetchRetry: conversations.isAwaitingInitialFetchRetry, + conversationIdentitySignature: _identitySignature(conversations.conversations), + processingIdentitySignature: _identitySignature(conversations.processingConversations), + recordingIdentitySignature: _identitySignature(recordings.recordings), + pendingDeleteCount: conversations.memoriesToDelete.length, + ); +} + +List<_ConversationListRow> _buildConversationListRows({ + required List dates, + required Map> conversationsByDate, + required Map> recordingsByDate, +}) { + final rows = <_ConversationListRow>[]; + var hasRenderedDate = false; + + for (var dateIndex = 0; dateIndex < dates.length; dateIndex++) { + final date = dates[dateIndex]; + final conversations = conversationsByDate[date] ?? const []; + final recordings = recordingsByDate[date] ?? const []; + final entries = buildConversationGroupEntries(conversations: conversations, recordings: recordings); + final conversationIndexes = { + for (var index = 0; index < conversations.length; index++) conversations[index].id: index, + }; + if (entries.isEmpty) continue; + + if (!hasRenderedDate) { + rows.add(( + kind: _ConversationListRowKind.topSpacer, + date: date, + isFirst: true, + conversation: null, + recording: null, + conversationIndex: -1, + )); + } + rows.add(( + kind: _ConversationListRowKind.dateHeader, + date: date, + isFirst: !hasRenderedDate, + conversation: null, + recording: null, + conversationIndex: -1, + )); + + for (final entry in entries) { + final conversation = entry.conversation; + final recording = entry.recording; + if (conversation != null) { + rows.add(( + kind: _ConversationListRowKind.conversation, + date: date, + isFirst: false, + conversation: conversation, + recording: null, + conversationIndex: conversationIndexes[conversation.id] ?? -1, + )); + } else { + rows.add(( + kind: _ConversationListRowKind.recording, + date: date, + isFirst: false, + conversation: null, + recording: recording, + conversationIndex: -1, + )); + } + } + + rows.add(( + kind: _ConversationListRowKind.groupSpacer, + date: date, + isFirst: false, + conversation: null, + recording: null, + conversationIndex: -1, + )); + hasRenderedDate = true; + } + + return rows; +} class ConversationsPage extends StatefulWidget { const ConversationsPage({super.key}); @@ -40,6 +182,9 @@ class _ConversationsPageState extends State with AutomaticKee final AppReviewService _appReviewService = AppReviewService(); final ScrollController _scrollController = ScrollController(); final GlobalKey _goalsWidgetKey = GlobalKey(); + String? _loadMoreFilterKey; + String? _lastLoadMoreRequestKey; + bool _isBootstrapping = true; void _refreshGoals() {} @@ -57,31 +202,88 @@ class _ConversationsPageState extends State with AutomaticKee WidgetsBinding.instance.addPostFrameCallback((_) async { if (!mounted) return; final conversationProvider = context.read(); - if (conversationProvider.conversations.isEmpty) { - await conversationProvider.getInitialConversations(); - } else { - // Still check for daily summaries even if conversations are cached - conversationProvider.checkHasDailySummaries(); + try { + if (conversationProvider.conversations.isEmpty) { + await conversationProvider.getInitialConversations(); + } else { + // Still check for daily summaries even if conversations are cached + _scheduleDeferred(conversationProvider.checkHasDailySummaries); + } + } finally { + if (mounted) setState(() => _isBootstrapping = false); } if (!mounted) return; - // Surface any unsynced batch recordings written by the native layer. - context.read().refresh(); + // Keep filesystem scanning off the first navigation/scroll frame. + _scheduleDeferred(context.read().refresh); // Load folders for folder tabs final folderProvider = context.read(); if (folderProvider.folders.isEmpty) { - await folderProvider.loadFolders(); + _scheduleDeferred(folderProvider.loadFolders); } // Check if we should show the app review prompt for first conversation if (mounted && conversationProvider.conversations.isNotEmpty) { - await _appReviewService.showReviewPromptIfNeeded(context, isProcessingFirstConversation: true); + _scheduleDeferred( + () => _appReviewService.showReviewPromptIfNeeded(context, isProcessingFirstConversation: true), + ); } }); } + void _scheduleDeferred(Future Function() operation) { + unawaited( + Future.delayed(const Duration(milliseconds: 200), () async { + if (!mounted) return; + try { + await operation(); + } catch (error, stackTrace) { + Logger.error('Deferred conversations-page work failed: $error\n$stackTrace'); + } + }), + ); + } + + bool _requestMoreIfNeeded(ConversationProvider provider) { + if (provider.isLoadingConversations) return false; + + final filterKey = [ + provider.previousQuery, + provider.selectedFolderId ?? '', + provider.selectedSpeakerId ?? '', + provider.selectedDate?.toIso8601String() ?? '', + provider.showStarredOnly, + ].join('|'); + if (_loadMoreFilterKey != filterKey) { + _loadMoreFilterKey = filterKey; + _lastLoadMoreRequestKey = null; + } + + final String pageOrCount; + final isSearch = provider.previousQuery.isNotEmpty || provider.selectedSpeakerId != null; + if (isSearch) { + if (provider.totalSearchPages <= provider.currentSearchPage) return false; + pageOrCount = 'page:${provider.currentSearchPage}'; + } else { + final count = provider.conversations.length + provider.memoriesToDelete.length; + if (count == 0 || count % 50 != 0) return false; + pageOrCount = 'count:$count'; + } + + final requestKey = '$filterKey|$pageOrCount'; + if (_lastLoadMoreRequestKey == requestKey) return false; + _lastLoadMoreRequestKey = requestKey; + + if (isSearch) { + unawaited(provider.searchMoreConversations()); + } else { + unawaited(provider.getMoreConversationsFromServer()); + } + return true; + } + void scrollToTop() { if (_scrollController.hasClients) { _scrollController.animateTo(0.0, duration: const Duration(milliseconds: 500), curve: Curves.easeOutCubic); @@ -241,12 +443,14 @@ class _ConversationsPageState extends State with AutomaticKee Widget build(BuildContext context) { Logger.debug('building conversations page'); super.build(context); - return Consumer( - builder: (context, convoProvider, child) { + return Selector2( + selector: (_, conversationProvider, recordingsProvider) => + _conversationPageSnapshot(conversationProvider, recordingsProvider), + builder: (context, snapshot, child) { + final convoProvider = context.read(); // Unsynced local recordings (batch/offline mode) shown inline with conversations, // grouped into the same date buckets. Only in the default view (no search/folder/ // starred/daily-summaries filter). - final recordingsProvider = context.watch(); final bool showRecordings = convoProvider.previousQuery.isEmpty && convoProvider.selectedFolderId == null && !convoProvider.showStarredOnly && @@ -255,19 +459,30 @@ class _ConversationsPageState extends State with AutomaticKee if (showRecordings) { // Batch/offline-mode recordings captured locally — a separate subsystem // from device offline-sync (which lives on the Sync page). - for (final rec in recordingsProvider.recordings) { + for (final rec in snapshot.recordings) { final dt = DateTime.fromMillisecondsSinceEpoch(rec.timerStart * 1000); final day = DateTime(dt.year, dt.month, dt.day); (recordingsByDate[day] ??= []).add(rec); } } final bool hasRecordings = recordingsByDate.isNotEmpty; + final bool isWaitingForInitialData = _isBootstrapping && snapshot.conversations.isEmpty && !hasRecordings; + final bool isShowingConversationSkeleton = isWaitingForInitialData || + convoProvider.isLoadingConversations || + convoProvider.isFetchingConversations || + convoProvider.isAwaitingInitialFetchRetry; final mergedDates = {...convoProvider.groupedConversations.keys, ...recordingsByDate.keys}.toList() ..sort((a, b) => b.compareTo(a)); + final conversationRows = _buildConversationListRows( + dates: mergedDates, + conversationsByDate: convoProvider.groupedConversations, + recordingsByDate: recordingsByDate, + ); return RefreshIndicator( onRefresh: () async { HapticFeedback.mediumImpact(); + _lastLoadMoreRequestKey = null; Provider.of(context, listen: false).refreshInProgressConversations(); // Refresh goals widget _goalsWidgetKey.currentState?.refresh(); @@ -275,7 +490,7 @@ class _ConversationsPageState extends State with AutomaticKee await Future.wait([ convoProvider.getInitialConversations(), Provider.of(context, listen: false).loadFolders(), - recordingsProvider.refresh(), + Provider.of(context, listen: false).refresh(), ]); }, color: Colors.deepPurpleAccent, @@ -290,9 +505,10 @@ class _ConversationsPageState extends State with AutomaticKee const SliverToBoxAdapter(child: ActiveCallBanner()), // Search bar - Consumer2( - builder: (context, homeProvider, convoProvider, _) { - bool shouldShowSearchBar = homeProvider.showConvoSearchBar || convoProvider.previousQuery.isNotEmpty; + Selector( + selector: (_, homeProvider) => homeProvider.showConvoSearchBar, + builder: (context, showConvoSearchBar, _) { + bool shouldShowSearchBar = showConvoSearchBar || convoProvider.previousQuery.isNotEmpty; if (!shouldShowSearchBar) { return const SliverToBoxAdapter(child: SizedBox.shrink()); } @@ -305,9 +521,10 @@ class _ConversationsPageState extends State with AutomaticKee getProcessingConversationsWidget(convoProvider.processingConversations), // Today's Tasks and Goals widgets - hide when showing daily recaps, search bar is active, or calendar filter is active - Consumer( - builder: (context, homeProvider, _) { - final isSearchActive = homeProvider.showConvoSearchBar || convoProvider.previousQuery.isNotEmpty; + Selector( + selector: (_, homeProvider) => homeProvider.showConvoSearchBar, + builder: (context, showConvoSearchBar, _) { + final isSearchActive = showConvoSearchBar || convoProvider.previousQuery.isNotEmpty; final hasCalendarFilter = convoProvider.selectedDate != null; final prefs = SharedPreferencesUtil(); if (convoProvider.showDailySummaries || isSearchActive || hasCalendarFilter) { @@ -319,7 +536,10 @@ class _ConversationsPageState extends State with AutomaticKee } return SliverToBoxAdapter( child: Column( - children: [if (showGoals) GoalsWidget(key: _goalsWidgetKey, onRefresh: _refreshGoals)], + children: [ + if (showGoals) + RepaintBoundary(child: GoalsWidget(key: _goalsWidgetKey, onRefresh: _refreshGoals)), + ], ), ); }, @@ -331,8 +551,7 @@ class _ConversationsPageState extends State with AutomaticKee // users get the empty-state hero below instead. if (convoProvider.showDailySummaries || _nonDiscardedConversationCount(convoProvider) > 0 || - convoProvider.isLoadingConversations || - convoProvider.isFetchingConversations || + isShowingConversationSkeleton || _hasActiveFilter(convoProvider)) SliverToBoxAdapter( child: Builder( @@ -358,11 +577,10 @@ class _ConversationsPageState extends State with AutomaticKee // clear it, even when the filtered result is empty. if (!convoProvider.showDailySummaries && (_nonDiscardedConversationCount(convoProvider) > 0 || - convoProvider.isLoadingConversations || - convoProvider.isFetchingConversations || + isShowingConversationSkeleton || _hasActiveFilter(convoProvider))) - Consumer2( - builder: (context, folderProvider, convoProvider, _) { + Consumer( + builder: (context, folderProvider, _) { return SliverToBoxAdapter( child: FolderTabs( folders: folderProvider.folders, @@ -384,18 +602,12 @@ class _ConversationsPageState extends State with AutomaticKee const DailySummariesList() else if (_nonDiscardedConversationCount(convoProvider) == 0 && !hasRecordings && - !convoProvider.isLoadingConversations && - !convoProvider.isFetchingConversations && - !convoProvider.isAwaitingInitialFetchRetry && + !isShowingConversationSkeleton && !_hasActiveFilter(convoProvider)) // Friendly hero for brand-new users with zero conversations — // matches the polished Tasks empty state. SliverFillRemaining(hasScrollBody: false, child: Center(child: _buildNoConversationsHero(context))) - else if (convoProvider.groupedConversations.isEmpty && - !hasRecordings && - !convoProvider.isLoadingConversations && - !convoProvider.isFetchingConversations && - !convoProvider.isAwaitingInitialFetchRetry) + else if (convoProvider.groupedConversations.isEmpty && !hasRecordings && !isShowingConversationSkeleton) SliverToBoxAdapter( child: Center( child: Padding( @@ -404,16 +616,12 @@ class _ConversationsPageState extends State with AutomaticKee ), ), ) - else if (convoProvider.groupedConversations.isEmpty && - !hasRecordings && - (convoProvider.isLoadingConversations || - convoProvider.isFetchingConversations || - convoProvider.isAwaitingInitialFetchRetry)) + else if (convoProvider.groupedConversations.isEmpty && !hasRecordings && isShowingConversationSkeleton) _buildLoadingShimmer() else SliverList( - delegate: SliverChildBuilderDelegate(childCount: mergedDates.length + 1, (context, index) { - if (index == mergedDates.length) { + delegate: SliverChildBuilderDelegate(childCount: conversationRows.length + 1, (context, index) { + if (index == conversationRows.length) { Logger.debug('loading more conversations'); if (convoProvider.isLoadingConversations) { return _buildLoadMoreShimmer(); @@ -422,39 +630,38 @@ class _ConversationsPageState extends State with AutomaticKee return VisibilityDetector( key: const Key('conversations-key'), onVisibilityChanged: (visibilityInfo) { - var provider = Provider.of(context, listen: false); - if (provider.previousQuery.isNotEmpty) { - if (visibilityInfo.visibleFraction > 0 && - !provider.isLoadingConversations && - (provider.totalSearchPages > provider.currentSearchPage)) { - provider.searchMoreConversations(); - } - } else { - if (visibilityInfo.visibleFraction > 0 && !convoProvider.isLoadingConversations) { - convoProvider.getMoreConversationsFromServer(); - } + if (visibilityInfo.visibleFraction > 0) { + _requestMoreIfNeeded(context.read()); } }, child: const SizedBox(height: 20, width: double.maxFinite), ); - } else { - var date = mergedDates[index]; - List memoriesForDate = - convoProvider.groupedConversations[date] ?? const []; - List recordingsForDate = recordingsByDate[date] ?? const []; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (index == 0) const SizedBox(height: 10), - ConversationsGroupWidget( - key: ValueKey(date), - isFirst: index == 0, - conversations: memoriesForDate, - recordings: recordingsForDate, - date: date, - ), - ], - ); + } + + final row = conversationRows[index]; + switch (row.kind) { + case _ConversationListRowKind.topSpacer: + return const SizedBox(height: 10); + case _ConversationListRowKind.dateHeader: + return DateListItem( + key: ValueKey('date_${row.date.toIso8601String()}'), + date: row.date, + isFirst: row.isFirst, + ); + case _ConversationListRowKind.conversation: + return ConversationListItem( + key: ValueKey(row.conversation!.id), + conversation: row.conversation!, + conversationIdx: row.conversationIndex, + date: row.date, + ); + case _ConversationListRowKind.recording: + return RecordingListItem( + key: ValueKey('rec_${row.recording!.id}'), + recording: row.recording!, + ); + case _ConversationListRowKind.groupSpacer: + return const SizedBox(height: 10); } }), ), diff --git a/app/lib/pages/conversations/widgets/conversation_list_item.dart b/app/lib/pages/conversations/widgets/conversation_list_item.dart index 8012b5729ce..3c0a2dc1464 100644 --- a/app/lib/pages/conversations/widgets/conversation_list_item.dart +++ b/app/lib/pages/conversations/widgets/conversation_list_item.dart @@ -1,9 +1,8 @@ import 'dart:async'; -import 'dart:ui'; - import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -46,6 +45,21 @@ class _ConversationListItemState extends State { Timer? _conversationNewStatusResetTimer; bool isNew = false; + int _visualSignature(ServerConversation conversation) => Object.hash( + conversation.structured.title, + conversation.structured.emoji, + conversation.structured.category, + conversation.status, + conversation.discarded, + conversation.starred, + conversation.folderId, + conversation.visibility, + conversation.startedAt, + conversation.finishedAt, + conversation.photos.length, + conversation.transcriptSegments.length, + ); + @override void dispose() { _conversationNewStatusResetTimer?.cancel(); @@ -70,267 +84,297 @@ class _ConversationListItemState extends State { }); } - return Consumer( - builder: (context, provider, child) { - final isSelectionMode = provider.isSelectionModeActive; - final isSelected = provider.isConversationSelected(widget.conversation.id); - final isMerging = provider.isConversationMerging(widget.conversation.id); - final isEligible = provider.isConversationEligibleForMerge(widget.conversation.id); - - return GestureDetector( - onTap: () async { - // If in selection mode, toggle selection only if eligible - if (isSelectionMode) { - if (!isEligible) { - // Show feedback that this conversation cannot be merged - HapticFeedback.lightImpact(); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(context.l10n.conversationCannotBeMerged), - duration: const Duration(seconds: 2), - ), - ); + return RepaintBoundary( + child: Selector< + ConversationProvider, + ({ + int visualSignature, + bool isSelectionMode, + bool isSelected, + bool isMerging, + bool isEligible, + })>( + selector: (context, provider) => ( + // ServerConversation is mutable. Select the visible primitive fields + // instead of object identity so star/title/status updates are not lost. + visualSignature: _visualSignature(widget.conversation), + isSelectionMode: provider.isSelectionModeActive, + isSelected: provider.isConversationSelected(widget.conversation.id), + isMerging: provider.isConversationMerging(widget.conversation.id), + isEligible: provider.isConversationEligibleForMerge(widget.conversation.id), + ), + builder: (context, rowState, child) { + final provider = context.read(); + final isSelectionMode = rowState.isSelectionMode; + final isSelected = rowState.isSelected; + final isMerging = rowState.isMerging; + final isEligible = rowState.isEligible; + + return GestureDetector( + onTap: () async { + // If in selection mode, toggle selection only if eligible + if (isSelectionMode) { + if (!isEligible) { + // Show feedback that this conversation cannot be merged + HapticFeedback.lightImpact(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.conversationCannotBeMerged), + duration: const Duration(seconds: 2), + ), + ); + return; + } + HapticFeedback.selectionClick(); + provider.toggleConversationSelection(widget.conversation.id); + return; + } + + if (widget.conversation.isLocked) { + if (!context.read().showSubscriptionUI) return; + PlatformManager.instance.analytics.paywallOpened('Conversation List Item'); + routeToPage(context, const UsagePage(showUpgradeDialog: true)); return; } HapticFeedback.selectionClick(); - provider.toggleConversationSelection(widget.conversation.id); - return; - } - - if (widget.conversation.isLocked) { - if (!context.read().showSubscriptionUI) return; - PlatformManager.instance.analytics.paywallOpened('Conversation List Item'); - routeToPage(context, const UsagePage(showUpgradeDialog: true)); - return; - } - // Calculate time difference - int hoursSinceConversation = DateTime.now().difference(widget.conversation.createdAt).inHours; - - // Check if user is searching - String searchQuery = provider.previousQuery; - if (searchQuery.isNotEmpty) { - // Track conversation opened from search - PlatformManager.instance.analytics.conversationOpenedFromSearch( - conversation: widget.conversation, - searchQuery: searchQuery, - conversationIndexInResults: widget.conversationIdx, - ); - } else { - // Track normal conversation list item click with time difference - PlatformManager.instance.analytics.conversationListItemClickedWithTimeDifference( - conversation: widget.conversation, - conversationIndex: widget.conversationIdx, - hoursSinceConversation: hoursSinceConversation, + // The detail page seeds its provider from the supplied conversation + // after its first frame. Notifying that provider before pushing the + // route delayed visible navigation and rebuilt listeners behind it. + final startingTitle = widget.conversation.structured.title; + + final searchQuery = provider.previousQuery; + final hoursSinceConversation = DateTime.now().difference(widget.conversation.createdAt).inHours; + final resultFuture = routeToPage( + context, + ConversationDetailPage(conversation: widget.conversation, isFromOnboarding: widget.isFromOnboarding), ); - } - - context.read().updateConversation(widget.conversation.id, widget.date); - String startingTitle = context.read().conversation.structured.title; - provider.onConversationTap(widget.conversation.id); - - var result = await routeToPage( - context, - ConversationDetailPage(conversation: widget.conversation, isFromOnboarding: widget.isFromOnboarding), - ); - if (context.mounted) { - // Don't upsert if the conversation was deleted while on the detail page - if (result is Map && result['deleted'] == true) return; - bool stillExists = provider.conversations.any((c) => c.id == widget.conversation.id); - if (stillExists) { - String newTitle = context.read().conversation.structured.title; - if (startingTitle != newTitle) { - widget.conversation.structured.title = newTitle; - provider.upsertConversation(widget.conversation); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + provider.onConversationTap(widget.conversation.id); + unawaited( + SchedulerBinding.instance.scheduleTask(() { + if (!mounted) return; + if (searchQuery.isNotEmpty) { + PlatformManager.instance.analytics.conversationOpenedFromSearch( + conversation: widget.conversation, + searchQuery: searchQuery, + conversationIndexInResults: widget.conversationIdx, + ); + } else { + PlatformManager.instance.analytics.conversationListItemClickedWithTimeDifference( + conversation: widget.conversation, + conversationIndex: widget.conversationIdx, + hoursSinceConversation: hoursSinceConversation, + ); + } + }, Priority.idle), + ); + }); + + var result = await resultFuture; + if (context.mounted) { + // Don't upsert if the conversation was deleted while on the detail page + if (result is Map && result['deleted'] == true) return; + bool stillExists = provider.conversations.any((c) => c.id == widget.conversation.id); + if (stillExists) { + String newTitle = context.read().conversation.structured.title; + if (startingTitle != newTitle) { + widget.conversation.structured.title = newTitle; + provider.upsertConversation(widget.conversation); + } } } - } - }, - onLongPress: () { - // Enter selection mode on long press - if (!isSelectionMode && !isMerging) { - HapticFeedback.mediumImpact(); - provider.enterSelectionMode(); - provider.toggleConversationSelection(widget.conversation.id); - } - }, - child: Stack( - children: [ - Padding( - padding: EdgeInsets.only( - top: 12, - left: widget.isFromOnboarding ? 0 : 16, - right: widget.isFromOnboarding ? 0 : 16, - ), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: (isSelectionMode && !isEligible) ? 0.6 : 1.0, - child: AnimatedContainer( + }, + onLongPress: () { + // Enter selection mode on long press + if (!isSelectionMode && !isMerging) { + HapticFeedback.mediumImpact(); + provider.enterSelectionMode(); + provider.toggleConversationSelection(widget.conversation.id); + } + }, + child: Stack( + children: [ + Padding( + padding: EdgeInsets.only( + top: 12, + left: widget.isFromOnboarding ? 0 : 16, + right: widget.isFromOnboarding ? 0 : 16, + ), + child: AnimatedOpacity( duration: const Duration(milliseconds: 200), - width: double.maxFinite, - decoration: BoxDecoration( - color: isSelected - ? Colors.deepPurple.withValues(alpha: 0.3) - : (isSelectionMode && !isEligible) - ? Colors.grey.shade800 - : const Color(0xFF1F1F25), - borderRadius: BorderRadius.circular(24.0), - border: isSelected - ? Border.all(color: Colors.deepPurple, width: 2) - : (isSelectionMode && !isEligible) - ? Border.all(color: Colors.grey.shade600, width: 1) - : null, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24.0), - child: Dismissible( - key: UniqueKey(), - direction: isSelectionMode || isMerging ? DismissDirection.none : DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20.0), - color: Colors.red, - child: const Icon(Icons.delete, color: Colors.white), - ), - confirmDismiss: (direction) async { - HapticFeedback.mediumImpact(); - bool showDeleteConfirmation = SharedPreferencesUtil().showConversationDeleteConfirmation; - - if (!showDeleteConfirmation) return Future.value(true); - - final connectivityProvider = Provider.of(context, listen: false); - - if (connectivityProvider.isConnected) { - bool dontShow = false; - return await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder( - builder: (context, setState) { - final checkbox = GestureDetector( - onTap: () => setState(() => dontShow = !dontShow), - child: Row( - children: [ - SizedBox( - width: 24, - height: 24, - child: Checkbox( - value: dontShow, - onChanged: (v) => setState(() => dontShow = v ?? false), - activeColor: Colors.deepPurple, - checkColor: Colors.white, - side: const BorderSide(color: Colors.white54), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + opacity: (isSelectionMode && !isEligible) ? 0.6 : 1.0, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.maxFinite, + decoration: BoxDecoration( + color: isSelected + ? Colors.deepPurple.withValues(alpha: 0.3) + : (isSelectionMode && !isEligible) + ? Colors.grey.shade800 + : const Color(0xFF1F1F25), + borderRadius: BorderRadius.circular(24.0), + border: isSelected + ? Border.all(color: Colors.deepPurple, width: 2) + : (isSelectionMode && !isEligible) + ? Border.all(color: Colors.grey.shade600, width: 1) + : null, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24.0), + child: Dismissible( + // Keep the dismissible state stable when the conversation provider + // refreshes. A UniqueKey here recreated every row during unrelated + // notifications, forcing extra layout/paint work while scrolling. + key: ValueKey('conversation_dismissible_${widget.conversation.id}'), + direction: isSelectionMode || isMerging ? DismissDirection.none : DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20.0), + color: Colors.red, + child: const Icon(Icons.delete, color: Colors.white), + ), + confirmDismiss: (direction) async { + HapticFeedback.mediumImpact(); + bool showDeleteConfirmation = SharedPreferencesUtil().showConversationDeleteConfirmation; + + if (!showDeleteConfirmation) return Future.value(true); + + final connectivityProvider = Provider.of(context, listen: false); + + if (connectivityProvider.isConnected) { + bool dontShow = false; + return await showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (context, setState) { + final checkbox = GestureDetector( + onTap: () => setState(() => dontShow = !dontShow), + child: Row( + children: [ + SizedBox( + width: 24, + height: 24, + child: Checkbox( + value: dontShow, + onChanged: (v) => setState(() => dontShow = v ?? false), + activeColor: Colors.deepPurple, + checkColor: Colors.white, + side: const BorderSide(color: Colors.white54), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), ), - ), - const SizedBox(width: 8), - Flexible( - child: Text( - context.l10n.dontShowAgain, - style: const TextStyle(fontSize: 14), + const SizedBox(width: 8), + Flexible( + child: Text( + context.l10n.dontShowAgain, + style: const TextStyle(fontSize: 14), + ), ), - ), + ], + ), + ); + final content = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.deleteConversationMessage), + const SizedBox(height: 16), + PlatformService.isApple + ? Material(color: Colors.transparent, child: checkbox) + : checkbox, ], - ), - ); - final content = Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.deleteConversationMessage), - const SizedBox(height: 16), - PlatformService.isApple - ? Material(color: Colors.transparent, child: checkbox) - : checkbox, - ], - ); - final actions = [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: Text(context.l10n.cancel, style: const TextStyle(color: Colors.white)), - ), - TextButton( - onPressed: () { - if (dontShow) { - SharedPreferencesUtil().showConversationDeleteConfirmation = false; - } - Navigator.of(ctx).pop(true); - }, - child: Text(context.l10n.confirm, style: const TextStyle(color: Colors.red)), - ), - ]; - if (PlatformService.isApple) { - return CupertinoAlertDialog( + ); + final actions = [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text(context.l10n.cancel, style: const TextStyle(color: Colors.white)), + ), + TextButton( + onPressed: () { + if (dontShow) { + SharedPreferencesUtil().showConversationDeleteConfirmation = false; + } + Navigator.of(ctx).pop(true); + }, + child: Text(context.l10n.confirm, style: const TextStyle(color: Colors.red)), + ), + ]; + if (PlatformService.isApple) { + return CupertinoAlertDialog( + title: Text(context.l10n.deleteConversationTitle), + content: content, + actions: actions, + ); + } + return AlertDialog( title: Text(context.l10n.deleteConversationTitle), content: content, actions: actions, ); - } - return AlertDialog( - title: Text(context.l10n.deleteConversationTitle), - content: content, - actions: actions, - ); - }, - ); - }, - ); - } else { - return showDialog( - builder: (c) => getDialog( - context, - () => Navigator.pop(context), - () => Navigator.pop(context), - context.l10n.unableToDeleteConversation, - context.l10n.pleaseCheckInternetConnectionAndTryAgain, - singleButton: true, - okButtonText: context.l10n.ok, - ), - context: context, - ); - } - }, - onDismissed: (direction) async { - var conversation = widget.conversation; - PlatformManager.instance.analytics.conversationSwipedToDelete(conversation); - provider.deleteConversationLocally(conversation, widget.date); - }, - child: Padding( - padding: PlatformService.isMobile - ? const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 20) - : const EdgeInsetsDirectional.all(16), - child: PlatformService.isMobile - ? _buildMobileLayout(context) - : Column( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _getConversationHeader(), - const SizedBox(height: 16), - _buildConversationBody(context), - ], + }, + ); + }, + ); + } else { + return showDialog( + builder: (c) => getDialog( + context, + () => Navigator.pop(context), + () => Navigator.pop(context), + context.l10n.unableToDeleteConversation, + context.l10n.pleaseCheckInternetConnectionAndTryAgain, + singleButton: true, + okButtonText: context.l10n.ok, ), + context: context, + ); + } + }, + onDismissed: (direction) async { + var conversation = widget.conversation; + PlatformManager.instance.analytics.conversationSwipedToDelete(conversation); + provider.deleteConversationLocally(conversation, widget.date); + }, + child: Padding( + padding: PlatformService.isMobile + ? const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 20) + : const EdgeInsetsDirectional.all(16), + child: PlatformService.isMobile + ? _buildMobileLayout(context) + : Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _getConversationHeader(), + const SizedBox(height: 16), + _buildConversationBody(context), + ], + ), + ), ), ), ), ), ), - ), - // Merging overlay covering the full card - if (isMerging) - Positioned.fill( - child: Padding( - padding: EdgeInsets.only( - top: 12, - left: widget.isFromOnboarding ? 0 : 16, - right: widget.isFromOnboarding ? 0 : 16, + // Merging overlay covering the full card + if (isMerging) + Positioned.fill( + child: Padding( + padding: EdgeInsets.only( + top: 12, + left: widget.isFromOnboarding ? 0 : 16, + right: widget.isFromOnboarding ? 0 : 16, + ), + child: _buildMergingOverlay(), ), - child: _buildMergingOverlay(), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ); } @@ -476,18 +520,18 @@ class _ConversationListItemState extends State { Widget _buildLockedOverlay() { return Positioned.fill( child: ClipRRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 2.0, sigmaY: 2.0), - child: Container( - alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.01), - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - context.l10n.upgradeToUnlimited, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), - ), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + // Avoid a live backdrop blur for every locked card. The opaque overlay + // preserves the locked affordance without making the scroll/route paint + // path sample and blur the entire card behind it. + color: Colors.black.withValues(alpha: 0.62), + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Text( + context.l10n.upgradeToUnlimited, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold), ), ), ), diff --git a/app/lib/pages/conversations/widgets/conversations_group_widget.dart b/app/lib/pages/conversations/widgets/conversations_group_widget.dart index 6e47aca38a3..a43055beb48 100644 --- a/app/lib/pages/conversations/widgets/conversations_group_widget.dart +++ b/app/lib/pages/conversations/widgets/conversations_group_widget.dart @@ -28,30 +28,45 @@ class ConversationsGroupWidget extends StatelessWidget { return const SizedBox.shrink(); } - // Merge conversations and recordings into one time-sorted list (newest first), - // matching how conversations are ordered within a date. - final entries = <({DateTime time, ServerConversation? convo, LocalRecording? rec})>[ - for (final c in conversations) (time: c.startedAt ?? c.createdAt, convo: c, rec: null), - for (final r in recordings) (time: r.startedAt, convo: null, rec: r), - ]..sort((a, b) => b.time.compareTo(a.time)); + final entries = buildConversationGroupEntries(conversations: conversations, recordings: recordings); return Column( mainAxisSize: MainAxisSize.min, children: [ DateListItem(date: date, isFirst: isFirst), ...entries.map((e) { - if (e.convo != null) { + if (e.conversation != null) { return ConversationListItem( - key: ValueKey(e.convo!.id), - conversation: e.convo!, - conversationIdx: conversations.indexOf(e.convo!), + key: ValueKey(e.conversation!.id), + conversation: e.conversation!, + conversationIdx: conversations.indexOf(e.conversation!), date: date, ); } - return RecordingListItem(key: ValueKey('rec_${e.rec!.id}'), recording: e.rec!); + return RecordingListItem(key: ValueKey('rec_${e.recording!.id}'), recording: e.recording!); }), const SizedBox(height: 10), ], ); } } + +typedef ConversationGroupEntry = ({ + DateTime time, + ServerConversation? conversation, + LocalRecording? recording, +}); + +/// Merge conversations and local recordings into the time-sorted order used by +/// the conversations page. The page consumes these lightweight descriptors in +/// a sliver builder so only visible rows become widgets. +List buildConversationGroupEntries({ + required List conversations, + required List recordings, +}) { + return [ + for (final conversation in conversations) + (time: conversation.startedAt ?? conversation.createdAt, conversation: conversation, recording: null), + for (final recording in recordings) (time: recording.startedAt, conversation: null, recording: recording), + ]..sort((a, b) => b.time.compareTo(a.time)); +} diff --git a/app/lib/pages/conversations/widgets/goals_widget.dart b/app/lib/pages/conversations/widgets/goals_widget.dart index 846255a5c7b..d66c157e2f9 100644 --- a/app/lib/pages/conversations/widgets/goals_widget.dart +++ b/app/lib/pages/conversations/widgets/goals_widget.dart @@ -11,6 +11,19 @@ import 'package:omi/backend/http/api/goals.dart'; import 'package:omi/providers/goals_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; +/// Keep integer stepping for small goals without asking RenderSlider to paint +/// one division per unit for arbitrarily large targets. +/// +/// A target of one billion previously produced one billion divisions. Flutter +/// walks the divisions during every slider paint even when tick marks are +/// hidden, which can block the UI thread for several seconds. +@visibleForTesting +int? goalSliderDivisions(double targetValue) { + if (!targetValue.isFinite || targetValue <= 0 || targetValue > 100) return null; + final roundedTarget = targetValue.round(); + return targetValue == roundedTarget ? roundedTarget : null; +} + /// Multi-goal widget supporting up to 3 goals with minimalistic UI class GoalsWidget extends StatefulWidget { const GoalsWidget({super.key, this.onRefresh}); @@ -585,8 +598,10 @@ class GoalsWidgetState extends State with WidgetsBindingObserver { }, child: GestureDetector( onTap: () { - PlatformManager.instance.analytics.goalItemTappedForEdit(goalId: goal.id, source: 'home'); _editGoal(goal); + WidgetsBinding.instance.addPostFrameCallback((_) { + PlatformManager.instance.analytics.goalItemTappedForEdit(goalId: goal.id, source: 'home'); + }); }, child: Container( margin: EdgeInsets.only(bottom: isLast ? 0 : 12), @@ -638,7 +653,7 @@ class GoalsWidgetState extends State with WidgetsBindingObserver { value: goal.currentValue.clamp(0.0, goal.targetValue), min: 0, max: goal.targetValue, - divisions: goal.targetValue >= 1 ? goal.targetValue.toInt() : null, + divisions: goalSliderDivisions(goal.targetValue), onChanged: (value) => _updateGoalProgressUI(goal, value), onChangeEnd: (value) { PlatformManager.instance.analytics.goalProgressChanged( diff --git a/app/lib/pages/home/home_content.dart b/app/lib/pages/home/home_content.dart index b32611ab9bc..0dc06dd737d 100644 --- a/app/lib/pages/home/home_content.dart +++ b/app/lib/pages/home/home_content.dart @@ -6,8 +6,10 @@ import 'package:provider/provider.dart'; import 'package:omi/backend/http/api/users.dart'; import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/backend/schema/daily_summary.dart'; import 'package:omi/pages/conversation_capturing/page.dart'; +import 'package:omi/pages/conversations/widgets/conversation_list_item.dart'; import 'package:omi/pages/conversations/widgets/processing_capture.dart'; import 'package:omi/pages/conversations/widgets/today_tasks_widget.dart'; import 'package:omi/pages/home/widgets/daily_summary_card.dart'; @@ -113,6 +115,21 @@ class HomeContentPageState extends State with AutomaticKeepAliv // big "get started" options so the home page doesn't feel // empty for new users. if (_nonDiscardedConversationCount(convoProvider) >= 3) ...[ + SliverToBoxAdapter( + child: _buildSectionHeader( + context, + context.l10n.conversations, + onViewAll: () { + // Reset the daily-summaries flag so the conversations tab + // actually shows conversations (it persists from Daily + // Recaps' View All otherwise). + if (convoProvider.showDailySummaries) convoProvider.toggleDailySummaries(); + context.read().setIndex(1); + }, + ), + ), + HomeConversationsPreview(conversationProvider: convoProvider), + // Mind Map section — only shown for users with enough activity. SliverToBoxAdapter( child: _buildSectionHeader( @@ -425,3 +442,71 @@ class HomeContentPageState extends State with AutomaticKeepAliv ); } } + +/// The filtered recent-conversation preview shown on Home for established users. +/// +/// This consumes [ConversationProvider.groupedConversations], which already +/// carries the conversations page's discarded/short/starred/date filters. +class HomeConversationsPreview extends StatelessWidget { + final ConversationProvider conversationProvider; + + const HomeConversationsPreview({super.key, required this.conversationProvider}); + + @override + Widget build(BuildContext context) { + if (conversationProvider.isLoadingConversations && conversationProvider.conversations.isEmpty) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: List.generate( + 2, + (_) => Padding( + padding: const EdgeInsets.only(top: 12), + child: ShimmerWithTimeout( + baseColor: AppStyles.backgroundSecondary, + highlightColor: AppStyles.backgroundTertiary, + child: Container( + height: 80, + decoration: BoxDecoration( + color: AppStyles.backgroundSecondary, + borderRadius: BorderRadius.circular(24), + ), + ), + ), + ), + ), + ), + ), + ); + } + + final sortedDates = conversationProvider.groupedConversations.keys.toList()..sort((a, b) => b.compareTo(a)); + final recent = []; + for (final date in sortedDates) { + final list = conversationProvider.groupedConversations[date] ?? const []; + for (final conversation in list) { + recent.add(conversation); + if (recent.length >= 3) break; + } + if (recent.length >= 3) break; + } + if (recent.isEmpty) return const SliverToBoxAdapter(child: SizedBox.shrink()); + + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: recent.length, + (context, index) { + final conversation = recent[index]; + final date = conversationLocalDayKey(conversation.startedAt ?? conversation.createdAt); + return ConversationListItem( + key: ValueKey(conversation.id), + conversation: conversation, + date: date, + conversationIdx: index, + ); + }, + ), + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 0604710b5cf..9ff437ec03f 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -71,6 +71,7 @@ import 'package:omi/utils/platform/platform_manager.dart'; import 'package:omi/utils/responsive/responsive_helper.dart'; import 'package:omi/widgets/calendar_date_picker_sheet.dart'; import 'package:omi/widgets/freemium_switch_dialog.dart'; +import 'package:omi/widgets/shimmer_with_timeout.dart'; import 'package:omi/widgets/upgrade_alert.dart'; import 'package:omi/widgets/bottom_nav_bar.dart'; import 'package:omi/pages/onboarding/interactive_device_onboarding/interactive_device_onboarding_wrapper.dart'; @@ -142,7 +143,6 @@ class HomePage extends StatefulWidget { class _HomePageState extends State with WidgetsBindingObserver, TickerProviderStateMixin { ForegroundUtil foregroundUtil = ForegroundUtil(); - List screens = [Container(), const SizedBox(), const SizedBox(), const SizedBox()]; final _upgrader = MyUpgrader(debugLogging: false, debugDisplayOnce: false); bool scriptsInProgress = false; @@ -152,7 +152,11 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker final GlobalKey> _conversationsPageKey = GlobalKey>(); final GlobalKey> _actionItemsPageKey = GlobalKey>(); final GlobalKey _appsPageKey = GlobalKey(); - late final List _pages; + // Keep the IndexedStack slots stable, but defer constructing non-selected + // tabs until the user visits them. Once created, a tab remains in the stack + // so its scroll position and other state are preserved. + final List _pages = List.filled(4, null); + final Set _scheduledPageInitializations = {}; // Freemium switch handler for auto-switch dialogs final FreemiumSwitchHandler _freemiumHandler = FreemiumSwitchHandler(); @@ -161,9 +165,60 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker DeviceProvider? _deviceProviderForQuickActions; CaptureProvider? _captureProviderForQuickActions; - void _initiateApps() { - context.read().getApps(); - context.read().getPopularApps(); + void _ensurePageInitialized(int pageIndex) { + if (pageIndex < 0 || pageIndex >= _pages.length || _pages[pageIndex] != null) return; + + switch (pageIndex) { + case 0: + _pages[pageIndex] = HomeContentPage(key: _homeContentPageKey); + break; + case 1: + _pages[pageIndex] = ConversationsPage(key: _conversationsPageKey); + break; + case 2: + _pages[pageIndex] = ActionItemsPage(key: _actionItemsPageKey, onAddGoal: _addGoal); + break; + case 3: + _pages[pageIndex] = AppsPage(key: _appsPageKey); + break; + } + } + + void _schedulePageInitialization(int pageIndex) { + if (pageIndex < 0 || pageIndex >= _pages.length || _pages[pageIndex] != null) return; + if (!_scheduledPageInitializations.add(pageIndex)) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + _scheduledPageInitializations.remove(pageIndex); + if (!mounted || _pages[pageIndex] != null) return; + setState(() => _ensurePageInitialized(pageIndex)); + }); + // addPostFrameCallback does not schedule a frame by itself. Background + // prewarming often runs while the UI is idle, so explicitly request one. + WidgetsBinding.instance.ensureVisualUpdate(); + } + + void _prewarmRemainingTabs(int selectedIndex) { + var delay = const Duration(milliseconds: 350); + for (var index = 0; index < _pages.length; index++) { + if (index == selectedIndex) continue; + final pageIndex = index; + Timer(delay, () { + if (!mounted) return; + _schedulePageInitialization(pageIndex); + }); + delay += const Duration(milliseconds: 180); + } + } + + List _buildPages(int selectedIndex) { + return [ + for (var index = 0; index < _pages.length; index++) + TickerMode( + enabled: index == selectedIndex, + child: RepaintBoundary(child: _pages[index] ?? _TabLoadingSkeleton(tabIndex: index)), + ), + ]; } void _scrollToTop(int pageIndex) { @@ -190,11 +245,15 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } void _addGoal() { + _ensurePageInitialized(1); context.read().setIndex(1); - final conversationsState = _conversationsPageKey.currentState; - if (conversationsState != null) { - (conversationsState as dynamic).addGoal(); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final conversationsState = _conversationsPageKey.currentState; + if (conversationsState != null) { + (conversationsState as dynamic).addGoal(); + } + }); } @override @@ -270,12 +329,6 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker @override void initState() { - _pages = [ - HomeContentPage(key: _homeContentPageKey), - ConversationsPage(key: _conversationsPageKey), - ActionItemsPage(key: _actionItemsPageKey, onAddGoal: _addGoal), - AppsPage(key: _appsPageKey), - ]; SharedPreferencesUtil().onboardingCompleted = true; if (!SharedPreferencesUtil().permissionsCompleted) { SharedPreferencesUtil().permissionsCompleted = true; @@ -315,7 +368,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Home controller context.read().selectedIndex = homePageIdx; + _ensurePageInitialized(homePageIdx); WidgetsBinding.instance.addObserver(this); + _prewarmRemainingTabs(homePageIdx); // Pre-warm agent VM and WebSocket so session is ready by the time the user opens chat if (SharedPreferencesUtil().claudeAgentEnabled) { @@ -329,8 +384,6 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } WidgetsBinding.instance.addPostFrameCallback((_) async { - _initiateApps(); - final permission = await Geolocator.checkPermission(); if (permission == LocationPermission.always || permission == LocationPermission.whileInUse) { await ForegroundUtil.initializeForegroundService(); @@ -709,12 +762,13 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker } return child!; }, - child: Consumer( - builder: (context, homeProvider, _) { + child: Selector( + selector: (_, homeProvider) => homeProvider.selectedIndex, + builder: (context, selectedIndex, _) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.primary, resizeToAvoidBottomInset: false, - appBar: homeProvider.selectedIndex == 5 ? null : _buildAppBar(context), + appBar: selectedIndex == 5 ? null : _buildAppBar(context), body: GestureDetector( onTap: () { primaryFocus?.unfocus(); @@ -726,9 +780,12 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker Column( children: [ // Show slim green call bar on non-home/conversations tabs when a call is active - if (homeProvider.selectedIndex > 1) const ActiveCallTopBar(), + if (selectedIndex > 1) const ActiveCallTopBar(), Expanded( - child: IndexedStack(index: context.watch().selectedIndex, children: _pages), + child: IndexedStack( + index: selectedIndex, + children: _buildPages(selectedIndex), + ), ), ], ), @@ -743,6 +800,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker return Stack( children: [ BottomNavBar( + // Queue page construction after the current + // gesture frame. Building a destination directly + // in onTapDown makes the tap itself feel stuck. + onTabWarmup: _schedulePageInitialization, onTabTap: (index, isRepeat) { if (isRepeat) { _scrollToTop(index); @@ -752,7 +813,12 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker final cp = context.read(); if (cp.showDailySummaries) cp.toggleDailySummaries(); } + // Change tabs immediately. If background + // prewarming has not completed yet, the + // destination paints a skeleton for one frame + // and mounts its real content afterwards. home.setIndex(index); + _schedulePageInitialization(index); } }, ), @@ -763,11 +829,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker }, ), // Merge action bar - floats above bottom nav when in selection mode - if (homeProvider.selectedIndex == 1) - const Positioned(left: 0, right: 0, bottom: 0, child: MergeActionBar()), + if (selectedIndex == 1) const Positioned(left: 0, right: 0, bottom: 0, child: MergeActionBar()), // Task selection action bar - floats above bottom nav on the // tasks tab when selection mode is active in ActionItemsProvider. - if (homeProvider.selectedIndex == 2) + if (selectedIndex == 2) const Positioned(left: 0, right: 0, bottom: 0, child: TaskSelectionActionBar()), ], ), @@ -1208,3 +1273,33 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker super.dispose(); } } + +class _TabLoadingSkeleton extends StatelessWidget { + const _TabLoadingSkeleton({required this.tabIndex}); + + final int tabIndex; + + @override + Widget build(BuildContext context) { + final itemCount = tabIndex == 3 ? 6 : 5; + return IgnorePointer( + child: ListView.builder( + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(20, 24, 20, 120), + itemCount: itemCount, + itemBuilder: (context, index) => Padding( + padding: const EdgeInsets.only(bottom: 14), + child: ShimmerWithTimeout( + baseColor: const Color(0xFF1F1F25), + highlightColor: const Color(0xFF303038), + child: Container( + height: index == 0 ? 34 : 76, + width: double.infinity, + decoration: BoxDecoration(color: const Color(0xFF1F1F25), borderRadius: BorderRadius.circular(18)), + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/pages/settings/daily_summary_detail_page.dart b/app/lib/pages/settings/daily_summary_detail_page.dart index 187e4304a6a..2c80e69cd00 100644 --- a/app/lib/pages/settings/daily_summary_detail_page.dart +++ b/app/lib/pages/settings/daily_summary_detail_page.dart @@ -20,8 +20,9 @@ import 'package:omi/utils/platform/platform_service.dart'; class DailySummaryDetailPage extends StatefulWidget { final String summaryId; final DailySummary? summary; // Can pass directly if already loaded + final TileProvider? tileProvider; - const DailySummaryDetailPage({super.key, required this.summaryId, this.summary}); + const DailySummaryDetailPage({super.key, required this.summaryId, this.summary, this.tileProvider}); @override State createState() => _DailySummaryDetailPageState(); @@ -640,6 +641,7 @@ class _DailySummaryDetailPageState extends State with Si subdomains: const ['a', 'b', 'c', 'd'], userAgentPackageName: 'me.omi.app', retinaMode: true, + tileProvider: widget.tileProvider, ), MarkerLayer(markers: markers), ], @@ -653,10 +655,8 @@ class _DailySummaryDetailPageState extends State with Si ...timelineLocations.asMap().entries.map((entry) { final index = entry.key; final location = entry.value; - final isFirst = index == 0; - final isLast = index == timelineLocations.length - 1; - return _buildTimelineItem(location, isFirst, isLast); + return _buildTimelineItem(location, index); }), ], ); @@ -869,7 +869,7 @@ class _DailySummaryDetailPageState extends State with Si ); } - Widget _buildTimelineItem(_TimelineLocation location, bool isFirst, bool isLast) { + Widget _buildTimelineItem(_TimelineLocation location, int index) { final startFormatted = _formatTimeTo12Hour(location.startTime); final endFormatted = _formatTimeTo12Hour(location.endTime); final timeText = startFormatted.isNotEmpty @@ -878,90 +878,54 @@ class _DailySummaryDetailPageState extends State with Si : startFormatted) : ''; - return GestureDetector( + final semanticsLabel = timeText.isEmpty ? location.shortName : '${location.shortName}, $timeText'; + + return Semantics( + container: true, + button: true, + excludeSemantics: true, + label: semanticsLabel, onTap: () => MapsUtil.launchMap(location.latitude, location.longitude), - child: IntrinsicHeight( - child: Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Timeline line and dot - SizedBox( - width: 40, - child: Column( - children: [ - // Top line (hidden for first item) - Container( - width: 2, - height: 12, - color: isFirst ? Colors.transparent : Colors.deepPurple.withValues(alpha: 0.4), - ), - // Dot - Container( - width: 14, - height: 14, - decoration: BoxDecoration( - color: Colors.deepPurple, - shape: BoxShape.circle, - border: Border.all(color: const Color(0xFF0A0A0A), width: 2), - boxShadow: [ - BoxShadow(color: Colors.deepPurple.withValues(alpha: 0.4), blurRadius: 6, spreadRadius: 1), - ], - ), - ), - // Bottom line (hidden for last item) - Expanded( - child: Container( - width: 2, - color: isLast ? Colors.transparent : Colors.deepPurple.withValues(alpha: 0.4), - ), - ), - ], - ), - ), - // Content - Expanded( - child: Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.fromLTRB(18, 14, 18, 14), - decoration: BoxDecoration(color: const Color(0xFF1A1A1F), borderRadius: BorderRadius.circular(20)), - child: Row( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => MapsUtil.launchMap(location.latitude, location.longitude), + child: Container( + key: ValueKey('daily_summary_location_row_$index'), + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: BoxDecoration(color: const Color(0xFF1A1A1F), borderRadius: BorderRadius.circular(16)), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + Text( + location.shortName, + style: const TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + height: 1.3, + ), + ), + if (timeText.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( children: [ - Text( - location.shortName, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w600, - height: 1.4, - ), - ), - if (timeText.isNotEmpty) ...[ - const SizedBox(height: 4), - Row( - children: [ - FaIcon(FontAwesomeIcons.clock, color: Colors.grey.shade500, size: 12), - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text(timeText, style: TextStyle(color: Colors.grey.shade500, fontSize: 13)), - ), - ], - ), - ], + FaIcon(FontAwesomeIcons.clock, color: Colors.grey.shade500, size: 12), + const SizedBox(width: 4), + Text(timeText, style: TextStyle(color: Colors.grey.shade500, fontSize: 13)), ], ), - ), - Icon(Icons.chevron_right, color: Colors.grey.shade600, size: 20), + ], ], ), ), - ), - ], + Icon(Icons.chevron_right, color: Colors.grey.shade600, size: 20), + ], + ), ), ), ); diff --git a/app/lib/providers/app_provider.dart b/app/lib/providers/app_provider.dart index 87202bdd5dc..32111349d5e 100644 --- a/app/lib/providers/app_provider.dart +++ b/app/lib/providers/app_provider.dart @@ -23,6 +23,15 @@ class AppProvider extends BaseProvider { @visibleForTesting Future Function(String appId)? disableAppOverride; + @visibleForTesting + Future>> Function()? retrieveAppsGroupedOverride; + + @visibleForTesting + Future> Function()? getEnabledAppsOverride; + + @visibleForTesting + Future> Function()? retrievePopularAppsOverride; + List apps = []; List popularApps = []; // v2 grouped apps: [{ category: {id,title}, data: List, pagination: {...} }] @@ -44,6 +53,13 @@ class AppProvider extends BaseProvider { bool isLoading = false; bool isSearching = false; + // Share an in-flight load with callers that arrive while the Apps tab is + // being initialized. This prevents a rebuild or deep link from starting a + // second catalog request while preserving explicit later refreshes. + Future? _appsLoad; + Future? _popularAppsLoad; + int _activeCatalogLoads = 0; + List categories = []; List capabilities = []; Map filters = {}; @@ -396,6 +412,16 @@ class AppProvider extends BaseProvider { notifyListeners(); } + void _beginCatalogLoad() { + _activeCatalogLoads++; + if (!isLoading) setIsLoading(true); + } + + void _endCatalogLoad() { + _activeCatalogLoads--; + if (_activeCatalogLoads == 0 && isLoading) setIsLoading(false); + } + void setSelectedChatAppId(String? appId) { final newAppId = appId ?? ""; if (selectedChatAppId != newAppId) { @@ -428,9 +454,28 @@ class AppProvider extends BaseProvider { notifyListeners(); } - Future getApps() async { - if (isLoading) return; - setIsLoading(true); + Future getApps() { + final inFlight = _appsLoad; + if (inFlight != null) return inFlight; + + final completer = Completer(); + final sharedLoad = completer.future; + _appsLoad = sharedLoad; + unawaited(() async { + try { + await _loadApps(); + completer.complete(); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + if (identical(_appsLoad, sharedLoad)) _appsLoad = null; + } + }()); + return sharedLoad; + } + + Future _loadApps() async { + _beginCatalogLoad(); try { // Performance optimization: Load from cache first for immediate UI @@ -440,8 +485,8 @@ class AppProvider extends BaseProvider { // Fetch grouped apps and user's enabled app IDs in parallel final results = await Future.wait([ - retrieveAppsGrouped(offset: 0, limit: 20, includeReviews: true), - getEnabledAppsServer(), + retrieveAppsGroupedOverride?.call() ?? retrieveAppsGrouped(offset: 0, limit: 20, includeReviews: true), + getEnabledAppsOverride?.call() ?? getEnabledAppsServer(), ]); final groups = results[0] as List>; final enabledAppIds = (results[1] as List).toSet(); @@ -472,22 +517,40 @@ class AppProvider extends BaseProvider { // Fallback to cached data setAppsFromCache(); } finally { - setIsLoading(false); + _endCatalogLoad(); } } - Future getPopularApps() async { - if (isLoading) return; // Prevent concurrent operations + Future getPopularApps() { + final inFlight = _popularAppsLoad; + if (inFlight != null) return inFlight; + + final completer = Completer(); + final sharedLoad = completer.future; + _popularAppsLoad = sharedLoad; + unawaited(() async { + try { + await _loadPopularApps(); + completer.complete(); + } catch (error, stackTrace) { + completer.completeError(error, stackTrace); + } finally { + if (identical(_popularAppsLoad, sharedLoad)) _popularAppsLoad = null; + } + }()); + return sharedLoad; + } + Future _loadPopularApps() async { + _beginCatalogLoad(); try { - setIsLoading(true); - popularApps = await retrievePopularApps(); + popularApps = await (retrievePopularAppsOverride?.call() ?? retrievePopularApps()); } catch (e) { Logger.debug('Error loading popular apps: $e'); // Fallback to cached data or empty list popularApps = []; } finally { - setIsLoading(false); + _endCatalogLoad(); } } diff --git a/app/lib/providers/conversation_provider.dart b/app/lib/providers/conversation_provider.dart index 3ded964b0c2..a58bf1b7b7e 100644 --- a/app/lib/providers/conversation_provider.dart +++ b/app/lib/providers/conversation_provider.dart @@ -22,6 +22,7 @@ typedef ConversationSearchFetcher = Future<(List, int, int)> required bool includeDiscarded, String? speakerId, }); +typedef ConversationDetailsFetcher = Future Function(String conversationId); /// Day-bucket key for a conversation timestamp, in the viewer's **local** timezone. /// @@ -98,6 +99,9 @@ class ConversationProvider extends ChangeNotifier { final ConversationSearchFetcher _conversationSearchFetcher; final bool Function() _isSignedIn; + @visibleForTesting + ConversationDetailsFetcher? conversationDetailsFetcherOverride; + ConversationProvider({ ConversationListFetcher? conversationListFetcher, DailySummariesChecker? dailySummariesChecker, @@ -159,19 +163,13 @@ class ConversationProvider extends ChangeNotifier { notifyListeners(); } - Future updateSearchedConvoDetails(String id, DateTime date, int idx) async { - var convo = await getConversationById(id); + Future updateSearchedConvoDetails(String id) async { + final convo = await (conversationDetailsFetcherOverride?.call(id) ?? getConversationById(id)); if (convo != null) { - updateSpecificGroupedConvo(convo, date, idx); + updateConversationInSortedList(convo); + } else { + notifyListeners(); } - notifyListeners(); - } - - void updateSpecificGroupedConvo(ServerConversation convo, DateTime date, int idx) { - final group = groupedConversations[date]; - if (group == null || idx < 0 || idx >= group.length) return; - group[idx] = convo; - notifyListeners(); } Future searchConversations(String query, {bool showShimmer = false}) async { @@ -264,9 +262,30 @@ class ConversationProvider extends ChangeNotifier { void onConversationTap(String conversationId) { final idx = conversations.indexWhere((c) => c.id == conversationId); if (idx == -1) return; + var changed = false; if (conversations[idx].isNew) { conversations[idx].isNew = false; - groupConversationsByDate(); + changed = true; + } + for (final conversation in searchedConversations) { + if (conversation.id == conversationId && conversation.isNew) { + conversation.isNew = false; + changed = true; + } + } + for (final group in groupedConversations.values) { + for (final conversation in group) { + if (conversation.id == conversationId && conversation.isNew) { + conversation.isNew = false; + changed = true; + } + } + } + if (changed) { + // A sync refresh can replace the grouped object while the canonical + // list still holds the old instance. Update every view by ID without + // rebuilding and sorting the entire list on a tap. + notifyListeners(); } } @@ -787,12 +806,18 @@ class ConversationProvider extends ChangeNotifier { } void updateConversationInSortedList(ServerConversation conversation) { - var effectiveDate = conversation.startedAt ?? conversation.createdAt; - var date = conversationLocalDayKey(effectiveDate); - if (groupedConversations.containsKey(date)) { - int idx = groupedConversations[date]!.indexWhere((element) => element.id == conversation.id); - if (idx != -1) { - groupedConversations[date]![idx] = conversation; + final canonicalIndex = conversations.indexWhere((element) => element.id == conversation.id); + if (canonicalIndex != -1) { + conversations[canonicalIndex] = conversation; + } + final searchedIndex = searchedConversations.indexWhere((element) => element.id == conversation.id); + if (searchedIndex != -1) { + searchedConversations[searchedIndex] = conversation; + } + for (final group in groupedConversations.values) { + final groupedIndex = group.indexWhere((element) => element.id == conversation.id); + if (groupedIndex != -1) { + group[groupedIndex] = conversation; } } notifyListeners(); diff --git a/app/lib/widgets/bottom_nav_bar.dart b/app/lib/widgets/bottom_nav_bar.dart index 64e8f6c4501..77c3c10da91 100644 --- a/app/lib/widgets/bottom_nav_bar.dart +++ b/app/lib/widgets/bottom_nav_bar.dart @@ -7,15 +7,27 @@ import 'package:provider/provider.dart'; import 'package:omi/providers/home_provider.dart'; -class BottomNavBar extends StatelessWidget { - const BottomNavBar({super.key, required this.onTabTap}); +class BottomNavBar extends StatefulWidget { + const BottomNavBar({super.key, required this.onTabTap, this.onTabWarmup}); final void Function(int index, bool isRepeat) onTabTap; + final ValueChanged? onTabWarmup; @override - Widget build(BuildContext context) { - return Consumer( - builder: (context, home, child) { + State createState() => _BottomNavBarState(); +} + +class _BottomNavBarState extends State { + // Keep the provider-dependent subtree stable when HomePage's broad Consumer + // rebuilds for unrelated focus or loading changes. + late final Widget _navigation; + + @override + void initState() { + super.initState(); + _navigation = Selector( + selector: (_, home) => home.selectedIndex, + builder: (context, selectedIndex, _) { return Align( alignment: Alignment.bottomCenter, child: Container( @@ -32,10 +44,10 @@ class BottomNavBar extends StatelessWidget { ), child: Row( children: [ - _buildTab(context, home, 0, FontAwesomeIcons.house, 'Home'), - _buildTab(context, home, 1, FontAwesomeIcons.comments, 'Conversations'), - _buildTab(context, home, 2, FontAwesomeIcons.listCheck, 'Tasks'), - _buildTab(context, home, 3, FontAwesomeIcons.puzzlePiece, 'Apps'), + _buildTab(context, selectedIndex, 0, FontAwesomeIcons.house, 'Home'), + _buildTab(context, selectedIndex, 1, FontAwesomeIcons.comments, 'Conversations'), + _buildTab(context, selectedIndex, 2, FontAwesomeIcons.listCheck, 'Tasks'), + _buildTab(context, selectedIndex, 3, FontAwesomeIcons.puzzlePiece, 'Apps'), ], ), ), @@ -44,18 +56,27 @@ class BottomNavBar extends StatelessWidget { ); } - Widget _buildTab(BuildContext context, HomeProvider home, int index, FaIconData icon, String label) { + @override + Widget build(BuildContext context) => _navigation; + + Widget _buildTab(BuildContext context, int selectedIndex, int index, FaIconData icon, String label) { return Expanded( child: InkWell( + onTapDown: (_) => widget.onTabWarmup?.call(index), onTap: () { - HapticFeedback.mediumImpact(); - PlatformManager.instance.analytics.bottomNavigationTabClicked(label); + // Switch the visible page before crossing the platform channel for + // haptics or analytics. Both can be delayed when the device is busy, + // but neither should delay visual acknowledgement of the tap. + widget.onTabTap(index, context.read().selectedIndex == index); primaryFocus?.unfocus(); - onTabTap(index, home.selectedIndex == index); + WidgetsBinding.instance.addPostFrameCallback((_) { + HapticFeedback.selectionClick(); + PlatformManager.instance.analytics.bottomNavigationTabClicked(label); + }); }, child: SizedBox( height: 90, - child: Center(child: FaIcon(icon, color: home.selectedIndex == index ? Colors.white : Colors.grey, size: 26)), + child: Center(child: FaIcon(icon, color: selectedIndex == index ? Colors.white : Colors.grey, size: 26)), ), ), ); diff --git a/app/scripts/setup_app_env_test.sh b/app/scripts/setup_app_env_test.sh new file mode 100644 index 00000000000..95f21de0967 --- /dev/null +++ b/app/scripts/setup_app_env_test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT_DIR" + +source setup.sh + +fixture_dir="$(mktemp -d "${TMPDIR:-/tmp}/omi-setup-env.XXXXXX")" +trap 'rm -rf "$fixture_dir"' EXIT + +printf '%s\n' \ + '# developer-owned setting' \ + 'STAGING_API_URL=https://staging.example.test/' \ + 'API_BASE_URL=https://old.example.test/' \ + 'USE_WEB_AUTH=false' \ + 'USE_AUTH_CUSTOM_TOKEN=false' \ + 'CUSTOM_FLAG=keep-me' >"$fixture_dir/.env" + +( + cd "$fixture_dir" + setup_app_env mobile_beta +) + +grep -Fx '# developer-owned setting' "$fixture_dir/.env" >/dev/null +grep -Fx 'STAGING_API_URL=https://staging.example.test/' "$fixture_dir/.env" >/dev/null +grep -Fx 'CUSTOM_FLAG=keep-me' "$fixture_dir/.env" >/dev/null +grep -Fx 'API_BASE_URL=https://api.omiapi.com/' "$fixture_dir/.env" >/dev/null +grep -Fx 'USE_WEB_AUTH=true' "$fixture_dir/.env" >/dev/null +grep -Fx 'USE_AUTH_CUSTOM_TOKEN=true' "$fixture_dir/.env" >/dev/null + +[[ "$(grep -c '^API_BASE_URL=' "$fixture_dir/.env")" == 1 ]] +[[ "$(grep -c '^USE_WEB_AUTH=' "$fixture_dir/.env")" == 1 ]] +[[ "$(grep -c '^USE_AUTH_CUSTOM_TOKEN=' "$fixture_dir/.env")" == 1 ]] + +echo "setup_app_env preserves unrelated keys and updates beta-owned keys" diff --git a/app/setup.sh b/app/setup.sh index 5ce7208a16d..8aa807ced75 100644 --- a/app/setup.sh +++ b/app/setup.sh @@ -47,6 +47,7 @@ echo "Usages:" echo "- bash setup.sh ios" echo "- bash setup.sh android" echo "- bash setup.sh ios beta # explicit production-data dogfood build" +echo "- bash setup.sh android beta # explicit production-data dogfood build" echo "" LOCAL_DEV_HOST="${OMI_DEV_HOST:-127.0.0.1}" @@ -90,8 +91,8 @@ function generate_ios_custom_config() { ###################################### function setup_firebase() { mkdir -p android/app/src/dev/ android/app/src/prod/ ios/Config/Dev/ ios/Config/Prod/ ios/Runner/ - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_dev.dart - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_prod.dart + cp lib/firebase_options_local.dart lib/firebase_options_dev.dart + cp lib/firebase_options_local.dart lib/firebase_options_prod.dart cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json cp setup/prebuilt/GoogleService-Info-Local.plist ios/Config/Dev/GoogleService-Info.plist @@ -155,7 +156,41 @@ function setup_app_env() { env_file='.env' api_base_url="$BETA_API_BASE_URL" fi - printf 'API_BASE_URL=%s\nUSE_WEB_AUTH=true\nUSE_AUTH_CUSTOM_TOKEN=true\n' "$api_base_url" > "$env_file" + + # Keep developer-owned settings and comments intact. These are the only keys + # owned by setup, so update them in place and replace the file atomically. + local temp_file source_file="/dev/null" + if [[ -f "$env_file" ]]; then source_file="$env_file"; fi + temp_file=$(mktemp "${env_file}.tmp.XXXXXX") + if ! awk \ + -v api_base_url="$api_base_url" \ + 'BEGIN { api_written = 0; web_auth_written = 0; custom_token_written = 0 } + /^[[:space:]]*API_BASE_URL[[:space:]]*=/ { + if (!api_written) { print "API_BASE_URL=" api_base_url; api_written = 1 } + next + } + /^[[:space:]]*USE_WEB_AUTH[[:space:]]*=/ { + if (!web_auth_written) { print "USE_WEB_AUTH=true"; web_auth_written = 1 } + next + } + /^[[:space:]]*USE_AUTH_CUSTOM_TOKEN[[:space:]]*=/ { + if (!custom_token_written) { print "USE_AUTH_CUSTOM_TOKEN=true"; custom_token_written = 1 } + next + } + { print } + END { + if (!api_written) print "API_BASE_URL=" api_base_url + if (!web_auth_written) print "USE_WEB_AUTH=true" + if (!custom_token_written) print "USE_AUTH_CUSTOM_TOKEN=true" + }' \ + "$source_file" 2>/dev/null >"$temp_file"; then + rm -f "$temp_file" + return 1 + fi + if ! mv "$temp_file" "$env_file"; then + rm -f "$temp_file" + return 1 + fi } # ####################### @@ -204,6 +239,7 @@ function run_build_ios() { } +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then case "${1}" in ios) if [[ "${2:-}" == "beta" ]]; then @@ -250,3 +286,4 @@ case "${1}" in exit 1 ;; esac +fi diff --git a/app/setup/prebuilt/firebase_options_local.dart b/app/setup/prebuilt/firebase_options_local.dart deleted file mode 100644 index d1b3b832239..00000000000 --- a/app/setup/prebuilt/firebase_options_local.dart +++ /dev/null @@ -1,62 +0,0 @@ -// Local-only Firebase options for the emulator harness. -// These values are intentionally non-secret placeholders. They keep the -// client project identity aligned with demo-omi-local while Auth is routed to -// the local emulator by main.dart. -// ignore_for_file: type=lint -import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; -import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; - -class DefaultFirebaseOptions { - static FirebaseOptions get currentPlatform { - if (kIsWeb) return web; - switch (defaultTargetPlatform) { - case TargetPlatform.android: - return android; - case TargetPlatform.iOS: - return ios; - case TargetPlatform.macOS: - return macos; - case TargetPlatform.windows: - case TargetPlatform.linux: - case TargetPlatform.fuchsia: - throw UnsupportedError( - 'Local Firebase options are not configured for this platform.', - ); - } - } - - static const android = FirebaseOptions( - apiKey: 'local-firebase-auth-emulator-api-key', - appId: '1:000000000000:android:omi-dev-local', - messagingSenderId: '000000000000', - projectId: 'demo-omi-local', - storageBucket: 'demo-omi-local.localhost', - ); - - static const ios = FirebaseOptions( - apiKey: 'local-firebase-auth-emulator-api-key', - appId: '1:000000000000:ios:omi-dev-local', - messagingSenderId: '000000000000', - projectId: 'demo-omi-local', - storageBucket: 'demo-omi-local.localhost', - iosBundleId: 'com.friend-app-with-wearable.ios12.development', - ); - - static const macos = FirebaseOptions( - apiKey: 'local-firebase-auth-emulator-api-key', - appId: '1:000000000000:ios:omi-dev-local', - messagingSenderId: '000000000000', - projectId: 'demo-omi-local', - storageBucket: 'demo-omi-local.localhost', - iosBundleId: 'com.friend-app-with-wearable.ios12.development', - ); - - static const web = FirebaseOptions( - apiKey: 'local-firebase-auth-emulator-api-key', - appId: '1:000000000000:web:omi-dev-local', - messagingSenderId: '000000000000', - projectId: 'demo-omi-local', - authDomain: 'demo-omi-local.firebaseapp.com', - storageBucket: 'demo-omi-local.localhost', - ); -} diff --git a/app/setup/scripts/setup.ps1 b/app/setup/scripts/setup.ps1 index b41011c1407..54c8090265c 100644 --- a/app/setup/scripts/setup.ps1 +++ b/app/setup/scripts/setup.ps1 @@ -46,14 +46,14 @@ function SetupFirebase { New-Item -ItemType Directory -Force -Path "android/app/src/dev/", "ios/Config/Dev/", "ios/Runner/" # Copy files - Copy-Item "setup/prebuilt/firebase_options_local.dart" -Destination "lib/firebase_options_dev.dart" + Copy-Item "lib/firebase_options_local.dart" -Destination "lib/firebase_options_dev.dart" Copy-Item "setup/prebuilt/google-services-local.json" -Destination "android/app/src/dev/google-services.json" Copy-Item "setup/prebuilt/GoogleService-Info-Local.plist" -Destination "ios/Config/Dev/GoogleService-Info.plist" Copy-Item "setup/prebuilt/GoogleService-Info-Local.plist" -Destination "ios/Runner/GoogleService-Info.plist" # Mocking setup New-Item -ItemType Directory -Force -Path "android/app/src/prod/", "ios/Config/Prod/" - Copy-Item "setup/prebuilt/firebase_options_local.dart" -Destination "lib/firebase_options_prod.dart" + Copy-Item "lib/firebase_options_local.dart" -Destination "lib/firebase_options_prod.dart" Copy-Item "setup/prebuilt/google-services-local.json" -Destination "android/app/src/prod/google-services.json" Copy-Item "setup/prebuilt/GoogleService-Info-Local.plist" -Destination "ios/Config/Prod/GoogleService-Info.plist" } diff --git a/app/test.sh b/app/test.sh index 3458e0d6e9a..b882cdb6780 100755 --- a/app/test.sh +++ b/app/test.sh @@ -23,7 +23,7 @@ if [[ ${#missing_files[@]} -gt 0 ]]; then echo "Running setup prerequisites..." mkdir -p android/app/src/dev/ ios/Config/Dev/ ios/Runner/ macos/ macos/Config/Dev - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_dev.dart + cp lib/firebase_options_local.dart lib/firebase_options_dev.dart cp setup/prebuilt/google-services-local.json android/app/src/dev/google-services.json cp setup/prebuilt/GoogleService-Info-Local.plist ios/Config/Dev/GoogleService-Info.plist cp setup/prebuilt/GoogleService-Info-Local.plist ios/Runner/GoogleService-Info.plist @@ -31,7 +31,7 @@ if [[ ${#missing_files[@]} -gt 0 ]]; then cp setup/prebuilt/GoogleService-Info-Local.plist macos/Config/Dev/GoogleService-Info.plist mkdir -p android/app/src/prod/ ios/Config/Prod/ macos/Config/Prod - cp setup/prebuilt/firebase_options_local.dart lib/firebase_options_prod.dart + cp lib/firebase_options_local.dart lib/firebase_options_prod.dart cp setup/prebuilt/google-services-local.json android/app/src/prod/google-services.json cp setup/prebuilt/GoogleService-Info-Local.plist ios/Config/Prod/GoogleService-Info.plist cp setup/prebuilt/GoogleService-Info-Local.plist macos/Config/Prod/GoogleService-Info.plist diff --git a/app/test/pages/conversations_group_widget_test.dart b/app/test/pages/conversations_group_widget_test.dart new file mode 100644 index 00000000000..d6cd675fa0d --- /dev/null +++ b/app/test/pages/conversations_group_widget_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/structured.dart'; +import 'package:omi/models/local_recording.dart'; +import 'package:omi/pages/conversations/widgets/conversations_group_widget.dart'; + +void main() { + test('conversation group entries preserve newest-first ordering across sources', () { + final olderConversation = _conversation('older', DateTime.utc(2026, 8, 10, 9)); + final newerRecording = _recording('newer.bin', DateTime.utc(2026, 8, 10, 10)); + final entries = buildConversationGroupEntries( + conversations: [olderConversation], + recordings: [newerRecording], + ); + + expect(entries.map((entry) => entry.conversation?.id ?? entry.recording?.id), ['newer.bin', 'older']); + }); +} + +ServerConversation _conversation(String id, DateTime createdAt) => ServerConversation( + id: id, + createdAt: createdAt, + structured: Structured('Title', 'Overview'), + ); + +LocalRecording _recording(String fileName, DateTime startedAt) => LocalRecording( + fileName: fileName, + filePath: '/tmp/$fileName', + timerStart: startedAt.millisecondsSinceEpoch ~/ 1000, + codec: BleAudioCodec.opus, + frameSize: 160, + sizeBytes: 1024, + seconds: 1, + state: LocalRecordingState.pending, + ); diff --git a/app/test/providers/app_provider_load_test.dart b/app/test/providers/app_provider_load_test.dart new file mode 100644 index 00000000000..4d4a045f41e --- /dev/null +++ b/app/test/providers/app_provider_load_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/app.dart'; +import 'package:omi/providers/app_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + test('coalesces duplicate catalog loads', () async { + final provider = AppProvider(); + addTearDown(provider.dispose); + final catalog = Completer>>(); + var requestCount = 0; + provider.retrieveAppsGroupedOverride = () { + requestCount++; + return catalog.future; + }; + provider.getEnabledAppsOverride = () async => []; + Future? reentrantLoad; + provider.addListener(() { + if (provider.isLoading) reentrantLoad ??= provider.getApps(); + }); + + final first = provider.getApps(); + final second = provider.getApps(); + + expect(requestCount, 1); + expect(provider.isLoading, isTrue); + catalog.complete(>[]); + await Future.wait([first, second, reentrantLoad!]); + expect(provider.isLoading, isFalse); + }); + + test('allows catalog and popular loads to overlap without clearing loading early', () async { + final provider = AppProvider(); + addTearDown(provider.dispose); + final catalog = Completer>>(); + final popular = Completer>(); + provider.retrieveAppsGroupedOverride = () => catalog.future; + provider.getEnabledAppsOverride = () async => []; + provider.retrievePopularAppsOverride = () => popular.future; + + final catalogLoad = provider.getApps(); + final popularLoad = provider.getPopularApps(); + expect(provider.isLoading, isTrue); + + popular.complete([]); + await popularLoad; + expect(provider.isLoading, isTrue); + + catalog.complete(>[]); + await catalogLoad; + expect(provider.isLoading, isFalse); + }); + + test('coalesces duplicate popular app loads', () async { + final provider = AppProvider(); + addTearDown(provider.dispose); + final popular = Completer>(); + var requestCount = 0; + provider.retrievePopularAppsOverride = () { + requestCount++; + return popular.future; + }; + + final first = provider.getPopularApps(); + final second = provider.getPopularApps(); + + expect(requestCount, 1); + popular.complete([]); + await Future.wait([first, second]); + expect(provider.isLoading, isFalse); + }); +} diff --git a/app/test/providers/conversation_provider_tap_test.dart b/app/test/providers/conversation_provider_tap_test.dart new file mode 100644 index 00000000000..f5204269ea7 --- /dev/null +++ b/app/test/providers/conversation_provider_tap_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/structured.dart'; +import 'package:omi/providers/conversation_provider.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + test('marking a conversation read does not regroup the entire list', () { + final conversation = ServerConversation( + id: 'c1', + createdAt: DateTime.utc(2026, 8, 10), + structured: Structured('Title', 'Overview'), + )..isNew = true; + final provider = ConversationProvider( + conversationListFetcher: () async => (items: [], ok: true), + isSignedIn: () => true, + ); + addTearDown(provider.dispose); + provider.conversations = [conversation]; + provider.groupConversationsByDate(); + final groupedBeforeTap = provider.groupedConversations; + var notificationCount = 0; + provider.addListener(() => notificationCount++); + + provider.onConversationTap(conversation.id); + + expect(conversation.isNew, isFalse); + expect(identical(provider.groupedConversations, groupedBeforeTap), isTrue); + expect(notificationCount, 1); + }); + + test('marking a synced replacement read updates canonical and grouped objects', () { + final original = _conversation('c1')..isNew = true; + final provider = _providerWith([original]); + addTearDown(provider.dispose); + provider.groupConversationsByDate(); + final replacement = _conversation('c1')..isNew = true; + + provider.updateConversationInSortedList(replacement); + provider.onConversationTap(replacement.id); + + expect(provider.conversations.single, same(replacement)); + expect(provider.conversations.single.isNew, isFalse); + expect(provider.groupedConversations.values.single.single, same(replacement)); + expect(provider.groupedConversations.values.single.single.isNew, isFalse); + }); + + test('detail enrichment re-locates the conversation after a list reorder', () async { + final original = _conversation('c1'); + final provider = _providerWith([original]); + addTearDown(provider.dispose); + provider.groupConversationsByDate(); + final date = provider.groupedConversations.keys.single; + final enrichment = _conversation('c1'); + final pending = Completer(); + provider.conversationDetailsFetcherOverride = (_) => pending.future; + + final update = provider.updateSearchedConvoDetails(original.id); + final other = _conversation('other', createdAt: original.createdAt.subtract(const Duration(minutes: 1))); + provider.groupedConversations[date]!.insert(0, other); + pending.complete(enrichment); + await update; + + expect(provider.groupedConversations[date]![0], same(other)); + expect( + provider.groupedConversations[date]!.firstWhere((conversation) => conversation.id == 'c1'), same(enrichment)); + }); +} + +ConversationProvider _providerWith(List conversations) { + final provider = ConversationProvider( + conversationListFetcher: () async => (items: [], ok: true), + isSignedIn: () => true, + ); + provider.conversations = conversations; + return provider; +} + +ServerConversation _conversation(String id, {DateTime? createdAt}) => ServerConversation( + id: id, + createdAt: createdAt ?? DateTime.utc(2026, 8, 10), + structured: Structured('Title', 'Overview'), + ); diff --git a/app/test/unit/env_test.dart b/app/test/unit/env_test.dart index 3b1d0001f01..179f8b73c63 100644 --- a/app/test/unit/env_test.dart +++ b/app/test/unit/env_test.dart @@ -97,6 +97,19 @@ void main() { AppEnvironmentProfile.localDev, ); }); + + test('production iOS config keeps the production Google redirect client id', () { + final prodConfig = File('ios/Flutter/prodRelease.xcconfig').readAsStringSync(); + + expect( + prodConfig, + contains('GOOGLE_REVERSE_CLIENT_ID=com.googleusercontent.apps.208440318997-ukinsq3sijhcetkhr26ssqp1terbq7as'), + ); + expect( + prodConfig, + isNot(contains('GOOGLE_REVERSE_CLIENT_ID=com.googleusercontent.apps.1031333818730-')), + ); + }); }); group('Env.apiBaseUrl', () { diff --git a/app/test/widgets/bottom_nav_bar_test.dart b/app/test/widgets/bottom_nav_bar_test.dart new file mode 100644 index 00000000000..3dee4d1a0ad --- /dev/null +++ b/app/test/widgets/bottom_nav_bar_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/providers/home_provider.dart'; +import 'package:omi/widgets/bottom_nav_bar.dart'; + +void main() { + test('HomeProvider preserves selection callback and notification semantics', () { + final provider = HomeProvider(); + addTearDown(provider.dispose); + + var notificationCount = 0; + final callbackIndices = []; + provider.addListener(() => notificationCount++); + provider.onSelectedIndexChanged = callbackIndices.add; + + provider.setIndex(2); + provider.setIndex(2); + + expect(provider.selectedIndex, 2); + expect(callbackIndices, [2, 2]); + expect(notificationCount, 2); + }); + + testWidgets('updates selection and detects a repeat tap before rebuilding', (tester) async { + final provider = HomeProvider(); + addTearDown(provider.dispose); + + final taps = <(int, bool)>[]; + final warmups = []; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + home: Scaffold( + body: BottomNavBar( + onTabWarmup: warmups.add, + onTabTap: (index, isRepeat) { + taps.add((index, isRepeat)); + provider.setIndex(index); + }, + ), + ), + ), + ), + ); + + expect(_colorFor(tester, FontAwesomeIcons.house), Colors.white); + expect(_colorFor(tester, FontAwesomeIcons.listCheck), Colors.grey); + + provider.setIndex(2); + await tester.pump(); + + expect(_colorFor(tester, FontAwesomeIcons.house), Colors.grey); + expect(_colorFor(tester, FontAwesomeIcons.listCheck), Colors.white); + + provider.setIndex(0); + await tester.pump(); + + await tester.tap(_findIcon(FontAwesomeIcons.listCheck)); + await tester.tap(_findIcon(FontAwesomeIcons.listCheck)); + + expect(taps, [(2, false), (2, true)]); + expect(warmups, [2, 2]); + }); +} + +Finder _findIcon(FaIconData icon) => find.byWidgetPredicate((widget) => widget is FaIcon && widget.icon == icon.data); + +Color _colorFor(WidgetTester tester, FaIconData icon) => tester.widget(_findIcon(icon)).color!; diff --git a/app/test/widgets/conversation_list_item_performance_test.dart b/app/test/widgets/conversation_list_item_performance_test.dart new file mode 100644 index 00000000000..c798c1d95fe --- /dev/null +++ b/app/test/widgets/conversation_list_item_performance_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/structured.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/conversations/widgets/conversation_list_item.dart'; +import 'package:omi/providers/conversation_provider.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + testWidgets('conversation rows are isolated behind a repaint boundary', (tester) async { + final conversation = ServerConversation( + id: 'c1', + createdAt: DateTime.utc(2020), + structured: Structured('A conversation', 'Overview'), + ); + final provider = ConversationProvider( + conversationListFetcher: () async => (items: [], ok: true), + isSignedIn: () => true, + )..conversations = [conversation]; + addTearDown(provider.dispose); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: ConversationListItem( + conversation: conversation, + date: DateTime.utc(2020), + conversationIdx: 0, + ), + ), + ), + ), + ); + + expect( + find.ancestor(of: find.byType(ConversationListItem), matching: find.byType(RepaintBoundary)), + findsAtLeastNWidgets(1), + ); + + provider.enterSelectionMode(); + await tester.pump(); + expect(find.byType(ConversationListItem), findsOneWidget); + }); +} diff --git a/app/test/widgets/conversation_markdown_widget_test.dart b/app/test/widgets/conversation_markdown_widget_test.dart new file mode 100644 index 00000000000..238ca116808 --- /dev/null +++ b/app/test/widgets/conversation_markdown_widget_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/pages/conversation_detail/widgets/conversation_markdown_widget.dart'; + +void main() { + testWidgets('conversation markdown is lazy inside a sliver viewport', (tester) async { + final content = List.generate(200, (index) => 'Block $index').join('\n\n'); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: Scaffold( + body: CustomScrollView( + slivers: [ConversationMarkdownSliver(content: content)], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SingleChildScrollView), findsNothing); + expect(find.text('Block 199'), findsNothing); + + await tester.fling(find.byType(CustomScrollView), const Offset(0, -100000), 10000); + await tester.pumpAndSettle(); + + expect(find.text('Block 199'), findsOneWidget); + }); + + testWidgets('conversation markdown preserves common block structures', (tester) async { + const content = '''# Heading + +- first item +- second item + +> quoted text + +```dart +final answer = 42; +``` +'''; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + home: const Scaffold( + body: CustomScrollView( + slivers: [ConversationMarkdownSliver(content: content)], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Heading'), findsOneWidget); + expect(find.text('first item'), findsOneWidget); + expect(find.text('second item'), findsOneWidget); + expect(find.text('quoted text'), findsOneWidget); + expect(find.text('final answer = 42;'), findsOneWidget); + }); +} diff --git a/app/test/widgets/daily_summary_detail_page_test.dart b/app/test/widgets/daily_summary_detail_page_test.dart new file mode 100644 index 00000000000..f302909c16e --- /dev/null +++ b/app/test/widgets/daily_summary_detail_page_test.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flutter_map/flutter_map.dart'; + +import 'package:omi/backend/schema/daily_summary.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/settings/daily_summary_detail_page.dart'; + +void main() { + testWidgets('renders journey locations as compact accessible map rows', (tester) async { + final semantics = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: ThemeData.dark(), + home: DailySummaryDetailPage( + summaryId: 'summary-1', + summary: _summary(), + tileProvider: _MemoryTileProvider(), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 900)); + + final firstRow = find.byKey(const ValueKey('daily_summary_location_row_0')); + final secondRow = find.byKey(const ValueKey('daily_summary_location_row_1')); + final contentWidth = tester.getSize(find.byType(Scaffold)).width - 40; + + expect(firstRow, findsOneWidget); + expect(secondRow, findsOneWidget); + expect(tester.getSize(firstRow).width, contentWidth); + expect(tester.getSize(firstRow).height, lessThan(60)); + expect(find.text('Home'), findsOneWidget); + expect(find.text('Office'), findsOneWidget); + + final rowSemantics = tester.widget( + find.ancestor(of: firstRow, matching: find.byType(Semantics)).first, + ); + expect(rowSemantics.properties.label, 'Home, 8AM'); + expect(rowSemantics.properties.button, isTrue); + expect(rowSemantics.properties.onTap, isNotNull); + semantics.dispose(); + }); +} + +DailySummary _summary() { + return DailySummary( + id: 'summary-1', + date: '2026-07-15', + createdAt: DateTime(2026, 7, 16), + headline: 'A day around the city', + overview: 'A productive day.', + stats: DayStats(totalConversations: 1, totalDurationMinutes: 30), + locations: [ + LocationPin(latitude: 37.7749, longitude: -122.4194, address: 'Home, San Francisco', time: '08:00'), + LocationPin(latitude: 37.7849, longitude: -122.4094, address: 'Office, San Francisco', time: '10:00'), + ], + ); +} + +class _MemoryTileProvider extends TileProvider { + static final _tile = MemoryImage( + base64Decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='), + ); + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => _tile; +} diff --git a/app/test/widgets/goals_widget_performance_test.dart b/app/test/widgets/goals_widget_performance_test.dart new file mode 100644 index 00000000000..5af63e51be2 --- /dev/null +++ b/app/test/widgets/goals_widget_performance_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/pages/conversations/widgets/goals_widget.dart'; + +void main() { + test('large goal targets do not create unbounded slider divisions', () { + expect(goalSliderDivisions(1000000000), isNull); + expect(goalSliderDivisions(double.infinity), isNull); + }); + + test('small integral goals retain one-step slider divisions', () { + expect(goalSliderDivisions(1), 1); + expect(goalSliderDivisions(20), 20); + expect(goalSliderDivisions(100), 100); + }); + + test('fractional and invalid targets use a continuous slider', () { + expect(goalSliderDivisions(2.5), isNull); + expect(goalSliderDivisions(0), isNull); + expect(goalSliderDivisions(-1), isNull); + }); +} diff --git a/app/test/widgets/home_conversations_preview_test.dart b/app/test/widgets/home_conversations_preview_test.dart new file mode 100644 index 00000000000..90a3b8309d7 --- /dev/null +++ b/app/test/widgets/home_conversations_preview_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/backend/schema/structured.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/conversations/widgets/conversation_list_item.dart'; +import 'package:omi/pages/home/home_content.dart'; +import 'package:omi/providers/conversation_provider.dart'; + +void main() { + testWidgets('home conversation preview shows the three newest filtered conversations', (tester) async { + final provider = ConversationProvider(isSignedIn: () => false); + addTearDown(provider.dispose); + + final today = DateTime.now(); + final yesterday = today.subtract(const Duration(days: 1)); + final conversations = [ + _conversation('newest', 'Newest', today), + _conversation('middle', 'Middle', today.subtract(const Duration(hours: 1))), + _conversation('yesterday', 'Yesterday', yesterday), + _conversation('older', 'Older', yesterday.subtract(const Duration(days: 1))), + ]; + provider.conversations = conversations; + provider.groupedConversations = { + DateTime(yesterday.year, yesterday.month, yesterday.day): [conversations[2], conversations[3]], + DateTime(today.year, today.month, today.day): [conversations[0], conversations[1]], + }; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.dark(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: ChangeNotifierProvider.value( + value: provider, + child: Scaffold( + body: CustomScrollView( + slivers: [HomeConversationsPreview(conversationProvider: provider)], + ), + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ConversationListItem), findsNWidgets(3)); + expect(find.text('Newest'), findsOneWidget); + expect(find.text('Middle'), findsOneWidget); + expect(find.text('Yesterday'), findsOneWidget); + expect(find.text('Older'), findsNothing); + }); +} + +ServerConversation _conversation(String id, String title, DateTime createdAt) { + return ServerConversation( + id: id, + createdAt: createdAt, + structured: Structured(title, 'Overview', emoji: '🧠'), + ); +} diff --git a/desktop/macos/Desktop/Tests/AuthTokenStorageTests.swift b/desktop/macos/Desktop/Tests/AuthTokenStorageTests.swift index 8b1c6204bab..0c930aa7997 100644 --- a/desktop/macos/Desktop/Tests/AuthTokenStorageTests.swift +++ b/desktop/macos/Desktop/Tests/AuthTokenStorageTests.swift @@ -400,5 +400,9 @@ final class AuthTokenStorageTests: XCTestCase { UserDefaults.standard.removeObject(forKey: .authTokenExpiry) UserDefaults.standard.removeObject(forKey: .authTokenUserId) UserDefaults.standard.removeObject(forKey: .authUserId) + UserDefaults.standard.removeObject(forKey: .automationOwnerOverride) + UserDefaults.standard.removeObject(forKey: .automationOwnerABackup) + UserDefaults.standard.removeObject(forKey: .authIsSignedIn) + UserDefaults.standard.removeObject(forKey: .authUserEmail) } }