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