diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-sign-in-film.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-sign-in-film.png new file mode 100644 index 00000000000..3e49fd2ccbb Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-sign-in-film.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-data-privacy-film.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-data-privacy-film.png new file mode 100644 index 00000000000..2fd205114ee Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-data-privacy-film.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-tasks-dot.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-tasks-dot.png new file mode 100644 index 00000000000..bcb146aacf8 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-tasks-dot.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-live-pill.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-live-pill.png new file mode 100644 index 00000000000..6f8d7559b52 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-live-pill.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/005-mobile-ios-chat-welcome.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/005-mobile-ios-chat-welcome.png new file mode 100644 index 00000000000..f2b60573802 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/005-mobile-ios-chat-welcome.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/006-mobile-ios-settings-upgrade.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/006-mobile-ios-settings-upgrade.png new file mode 100644 index 00000000000..9221f078cd9 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/006-mobile-ios-settings-upgrade.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/evidence.json b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/evidence.json new file mode 100644 index 00000000000..ad6b81b02cf --- /dev/null +++ b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/evidence.json @@ -0,0 +1,47 @@ +{ + "version": 1, + "images": [ + { + "file": "001-mobile-ios-sign-in-film.png", + "platform": "mobile-ios", + "description": "Sign-in over the launch film, no Get started page (local test build shows one local sign-in button where store builds show Apple/Google)", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "002-mobile-ios-data-privacy-film.png", + "platform": "mobile-ios", + "description": "Data & Privacy over the launch film", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "003-mobile-ios-home-tasks-dot.png", + "platform": "mobile-ios", + "description": "Home after onboarding: the first conversation and a dot on Tasks", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "004-mobile-ios-home-live-pill.png", + "platform": "mobile-ios", + "description": "Bottom-bar live pill while the phone records (local server without transcription, so Reconnecting also shows)", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "005-mobile-ios-chat-welcome.png", + "platform": "mobile-ios", + "description": "Omi's first chat message in an empty chat", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "006-mobile-ios-settings-upgrade.png", + "platform": "mobile-ios", + "description": "The paywall experiment's arm opening in place (Settings > Upgrade; the app-open re-ask opens the same paywall). Outline arm forced", + "captured_by": "agent", + "source": "manual" + } + ] +} diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png b/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png new file mode 100644 index 00000000000..a5416d7e376 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png b/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png new file mode 100644 index 00000000000..d3d14ff89a3 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json b/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json new file mode 100644 index 00000000000..70f62841e9f --- /dev/null +++ b/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "images": [ + { + "file": "001-mobile-ios-settings-before-light.png", + "platform": "mobile-ios", + "description": "Settings sheet before (Free plan, widget-test render of #21711's head): full-width Upgrade, Referral with a NEW tag beside Plan, one long group of eight rows", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "002-mobile-ios-settings-after-light.png", + "platform": "mobile-ios", + "description": "Settings sheet after, iOS simulator (card arm, Free plan, no device): no handle band, plan card with the plan name large and a full-width Upgrade, Account initial, Device (Disconnected) / Recording / Notifications / Privacy first", + "captured_by": "agent", + "source": "manual" + } + ] +} diff --git a/app/assets/videos/onboarding_launch_loop.mp4 b/app/assets/videos/onboarding_launch_loop.mp4 new file mode 100644 index 00000000000..bf06a90049c Binary files /dev/null and b/app/assets/videos/onboarding_launch_loop.mp4 differ diff --git a/app/changelog/unreleased/20261015-home-redesign-upgrade.json b/app/changelog/unreleased/20261015-home-redesign-upgrade.json new file mode 100644 index 00000000000..d601295183c --- /dev/null +++ b/app/changelog/unreleased/20261015-home-redesign-upgrade.json @@ -0,0 +1,3 @@ +{ + "change": "A calmer Home: recording lives in the bottom bar, new conversations read as they form, and upgrading opens right where you are" +} diff --git a/app/changelog/unreleased/20261015-settings-order.json b/app/changelog/unreleased/20261015-settings-order.json new file mode 100644 index 00000000000..844f1ae2ba6 --- /dev/null +++ b/app/changelog/unreleased/20261015-settings-order.json @@ -0,0 +1,3 @@ +{ + "change": "Settings puts what you use most first: Device (with battery), Recording & Transcription, Notifications and Privacy" +} diff --git a/app/contracts/addressability/unkeyed-baseline.json b/app/contracts/addressability/unkeyed-baseline.json index 4b617dd64fd..e29afd4becb 100644 --- a/app/contracts/addressability/unkeyed-baseline.json +++ b/app/contracts/addressability/unkeyed-baseline.json @@ -98,10 +98,8 @@ "app/lib/pages/onboarding/apple_watch_permission_page.dart": 9, "app/lib/pages/onboarding/auth.dart": 6, "app/lib/pages/onboarding/complete_screen.dart": 2, - "app/lib/pages/onboarding/device_selection.dart": 2, "app/lib/pages/onboarding/find_device/found_devices.dart": 1, "app/lib/pages/onboarding/find_device/page.dart": 2, - "app/lib/pages/onboarding/found_omi/found_omi_widget.dart": 4, "app/lib/pages/onboarding/interactive_device_onboarding/interactive_device_onboarding_wrapper.dart": 1, "app/lib/pages/onboarding/interactive_device_onboarding/steps/double_press_config_step.dart": 1, "app/lib/pages/onboarding/interactive_device_onboarding/widgets/onboarding_intro_screen.dart": 2, diff --git a/app/docs/ux-contract.md b/app/docs/ux-contract.md index e0c7c3379b9..51f10dd4dc1 100644 --- a/app/docs/ux-contract.md +++ b/app/docs/ux-contract.md @@ -66,6 +66,8 @@ There are exactly two ways out, and they mean different things. widget. It owns the top radius (`OmiRadius.xl`), the 36×4 drag handle, the optional title row with a trailing `OmiCloseButton`, safe-area and keyboard insets, and `isScrollControlled`. Never a raw `showModalBottomSheet` (`raw-bottom-sheet`) and never a hand-drawn handle. +- The Settings sheet drops the handle band (`showDragHandle: false`): its own header, with the close X, + is the top of the sheet, and it still swipes down to close. - A sheet nothing may dismiss (a required update) passes `isDismissible: false, enableDrag: false` and blocks back with `PopScope`, and has no handle that promises a swipe: `showOmiSheet` drops it itself; `showOmiSurfaceSheet` takes `showDragHandle: false`. @@ -307,6 +309,11 @@ guidance. Open Settings) — then the system prompt. A screen's Continue asks, in row order, for every permission still askable (`requestMissingOnboardingPermissions`), then moves on whatever the reader chose. +- First-run exception (`pages/onboarding/permissions/permissions_widget.dart`): one switch card per + permission; toggling a card shows its system prompt. Continue unlocks once every card is + **answered**, never only when granted: a "no" turns the card orange (notifications red with + "Tap to fix", which opens Settings) and the reader moves on. Bluetooth is asked as the app + opens, so its card appears only when it was not allowed. - Permanently denied → the row says so and offers **Open Settings** (`l10n.openSettings`). - Ask when a feature needs the permission ("Always" location only when a feature requires it). diff --git a/app/integration_test/app_performance_test.dart b/app/integration_test/app_performance_test.dart index b0384e1edab..2dc301ff134 100644 --- a/app/integration_test/app_performance_test.dart +++ b/app/integration_test/app_performance_test.dart @@ -95,7 +95,7 @@ void main() { debugPrint('[4/7] Navigating to CHAT screen...'); // Find and tap the "Ask Omi" button - final askOmiButton = find.text('Ask Omi'); + final askOmiButton = find.byKey(const ValueKey('home_ask_omi_button')); if (askOmiButton.evaluate().isNotEmpty) { await tester.tap(askOmiButton); await _pumpFor(tester, 2000); @@ -185,7 +185,7 @@ Future _handleOnboardingIfNeeded(WidgetTester tester) async { debugPrint('[2/7] Checking for onboarding...'); // Check if we're already on home screen - final askOmi = find.text('Ask Omi'); + final askOmi = find.byKey(const ValueKey('home_ask_omi_button')); if (askOmi.evaluate().isNotEmpty) { debugPrint(' Already logged in - on home screen'); return; @@ -275,7 +275,7 @@ Future _handleAuthFlow(WidgetTester tester) async { await tester.pump(const Duration(milliseconds: 500)); // Check if we've reached home screen - final askOmi = find.text('Ask Omi'); + final askOmi = find.byKey(const ValueKey('home_ask_omi_button')); if (askOmi.evaluate().isNotEmpty) { debugPrint(' Sign-in complete - reached home screen'); return; @@ -377,7 +377,7 @@ Future _handleUserReviewScreen(WidgetTester tester) async { } // Check if we've already passed to home screen - final askOmi = find.text('Ask Omi'); + final askOmi = find.byKey(const ValueKey('home_ask_omi_button')); if (askOmi.evaluate().isNotEmpty) { debugPrint(' Already on home screen - skipping user review'); return; @@ -484,7 +484,7 @@ Future _handleSpeechProfileScreen(WidgetTester tester) async { await _pumpFor(tester, 500); // Check if we're already on home screen - final askOmi = find.text('Ask Omi'); + final askOmi = find.byKey(const ValueKey('home_ask_omi_button')); if (askOmi.evaluate().isNotEmpty) { debugPrint(' Onboarding complete - reached home screen'); return; diff --git a/app/integration_test/visual_audit/scenarios/capture.dart b/app/integration_test/visual_audit/scenarios/capture.dart index 98622cfe938..5114b879002 100644 --- a/app/integration_test/visual_audit/scenarios/capture.dart +++ b/app/integration_test/visual_audit/scenarios/capture.dart @@ -1,7 +1,7 @@ // Home's capture surfaces where the app really draws them: the live card at the top of Home, the -// record button to the right of the floating Ask Omi bar, the device chip in the header, -// the sheets they open, and the live page. The frame mirrors HomePage's layout; its Ask Omi bar -// is a copy of HomePage._buildChatBar (private there). +// record pill beside the Ask Omi button at the bottom ("+ New", "Stop", or the capturing pendant), +// the device chip in the header, the sheets they open, and the live page. The frame mirrors +// HomePage's layout. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; @@ -14,7 +14,9 @@ import 'package:omi/backend/schema/phone_call.dart'; import 'package:omi/backend/schema/transcript_segment.dart'; import 'package:omi/pages/conversation_capturing/page.dart'; import 'package:omi/pages/home/home_content.dart'; +import 'package:omi/pages/action_items/widgets/tasks_more_menu.dart'; import 'package:omi/pages/home/widgets/battery_info_widget.dart'; +import 'package:omi/pages/home/widgets/home_bottom_controls.dart'; import 'package:omi/pages/home/widgets/home_daily_recaps.dart'; import 'package:omi/pages/home/widgets/home_tab_switcher.dart'; import 'package:omi/backend/schema/daily_summary.dart'; @@ -209,7 +211,18 @@ class HomeFrame extends StatelessWidget { ), body: Stack(children: [ Column(children: [ - HomeTabSwitcher(onTabTap: (_, __) {}), + // The selected page's one action beside the switcher, as HomePage draws it. + HomeTabSwitcher( + onTabTap: (_, __) {}, + trailing: tasks != null + ? const TasksMoreMenu() + : OmiIconButton( + icon: const FaIcon(FontAwesomeIcons.calendar, size: 18), + label: 'Filter by date', + color: OmiColors.textSecondary, + onPressed: () {}, + ), + ), Expanded(child: tasks ?? HomeContentPage(loadRecaps: recaps)), ]), if (tasks == null) const HomeChatBarBackdrop(), @@ -218,40 +231,17 @@ class HomeFrame extends StatelessWidget { left: 16, right: 16, bottom: homeChatBarOffset(context), - child: const Row(children: [Expanded(child: _AskOmiBar()), SizedBox(width: 10), HomeRecordButton()]), + child: Row(children: [ + HomeChatButton(onTap: () {}, onLongPress: () {}), + const SizedBox(width: 10), + const Expanded(child: HomeRecordButton()), + ]), ), ]), ); } } -/// Copy of HomePage._buildChatBar's look. -class _AskOmiBar extends StatelessWidget { - const _AskOmiBar(); - - @override - Widget build(BuildContext context) => Container( - height: kHomeChatBarHeight, - decoration: BoxDecoration( - color: OmiColors.surface1, - borderRadius: OmiRadius.pillAll, - border: Border.all(color: OmiColors.border, width: 1), - ), - child: Row(children: [ - const SizedBox(width: 18), - Expanded(child: Text('Ask Omi', style: OmiType.subhead.copyWith(color: OmiColors.textTertiary))), - Container( - width: 42, - height: 42, - margin: const EdgeInsets.only(right: 6), - alignment: Alignment.center, - decoration: BoxDecoration(color: OmiColors.accent, shape: BoxShape.circle), - child: FaIcon(FontAwesomeIcons.microphone, size: 15, color: OmiColors.onAccent), - ), - ]), - ); -} - Future _runHome( AuditRun a, AuditLive live, { @@ -390,11 +380,11 @@ final captureScenarios = [ ), AuditScenario( id: 'home-capture-pendant-tap', - title: 'Tap the record button while the pendant records', + title: 'Hold the pendant pill while the pendant records', page: _home, - state: 'The pendant streams; the record button is tapped', + state: 'The pendant streams; its pill (in place of + New) is held', run: (a) => _runHome(a, AuditLive.pendant, - pendantConnected: true, tap: find.byType(HomeRecordButton), action: 'Tap the record button'), + pendantConnected: true, longPress: find.byType(HomeRecordButton), action: 'Hold the pendant pill'), ), AuditScenario( id: 'home-capture-phone-after-pendant', @@ -421,9 +411,9 @@ final captureScenarios = [ id: 'home-capture-options', title: 'Other ways to record', page: _home, - state: 'Nothing recording; the record button is held (the ⌄ badge opens the same sheet)', + state: 'Nothing recording; + New is held', run: (a) async { - await _runHome(a, AuditLive.idle, longPress: find.byType(HomeRecordButton), action: 'Hold the record button'); + await _runHome(a, AuditLive.idle, longPress: find.byType(HomeRecordButton), action: 'Hold + New'); }, ), AuditScenario( diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 05bad864d0a..6944f376b6e 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -996,6 +996,11 @@ class SharedPreferencesUtil { set hasSpeakerProfile(bool value) => saveBool('hasSpeakerProfile', value); + /// The reader closed Home's "Teach Omi your voice" card; Settings > Voice Profile still has it. + bool get voiceProfileCardDismissed => getBool('voiceProfileCardDismissed'); + + set voiceProfileCardDismissed(bool value) => saveBool('voiceProfileCardDismissed', value); + bool get showDiscardedMemories => getBool('showDiscardedMemories', defaultValue: false); set showDiscardedMemories(bool value) => saveBool('showDiscardedMemories', value); diff --git a/app/lib/gen/assets.gen.dart b/app/lib/gen/assets.gen.dart index 0bde56a9f1c..02a5223b3ed 100644 --- a/app/lib/gen/assets.gen.dart +++ b/app/lib/gen/assets.gen.dart @@ -277,9 +277,6 @@ class $AssetsImagesGen { AssetGenImage get onboardingBg6 => const AssetGenImage('assets/images/onboarding-bg-6.webp'); - /// File path: assets/images/onboarding.mp4 - String get onboarding => 'assets/images/onboarding.mp4'; - /// File path: assets/images/plaud_note_pin.webp AssetGenImage get plaudNotePin => const AssetGenImage('assets/images/plaud_note_pin.webp'); @@ -394,7 +391,6 @@ class $AssetsImagesGen { onboardingBg51, onboardingBg52, onboardingBg6, - onboarding, plaudNotePin, raybanMeta, recordingGreenCircleIcon, @@ -505,7 +501,17 @@ class $AssetsTestGen { List get values => [ttsProgressiveProbe]; } -abstract final class Assets { +class $AssetsVideosGen { + const $AssetsVideosGen(); + + /// File path: assets/videos/onboarding_launch_loop.mp4 + String get onboardingLaunchLoop => 'assets/videos/onboarding_launch_loop.mp4'; + + /// List of all assets + List get values => [onboardingLaunchLoop]; +} + +class Assets { static const $AssetsCompetitorLogosGen competitorLogos = $AssetsCompetitorLogosGen(); static const $AssetsFontsGen fonts = $AssetsFontsGen(); @@ -514,6 +520,7 @@ abstract final class Assets { $AssetsIntegrationAppLogosGen(); static const $AssetsModelsGen models = $AssetsModelsGen(); static const $AssetsTestGen test = $AssetsTestGen(); + static const $AssetsVideosGen videos = $AssetsVideosGen(); static const String shorebird = 'shorebird.yaml'; /// List of all assets diff --git a/app/lib/l10n/app_ar.arb b/app/lib/l10n/app_ar.arb index e23ae434ef5..d81695e2b1d 100644 --- a/app/lib/l10n/app_ar.arb +++ b/app/lib/l10n/app_ar.arb @@ -1512,7 +1512,7 @@ "durationSeconds": "المدة: {seconds} ثانية", "additionalSpeechSampleRemoved": "تمت إزالة عينة الكلام الإضافية", "consentDataMessage": "بالمتابعة، سيتم تخزين محادثاتك وتسجيلاتك ومعلوماتك الشخصية بشكل آمن على خوادمنا. تتم معالجة تسجيلاتك الصوتية ونصوصك بواسطة خدمات ذكاء اصطناعي تابعة لجهات خارجية (بما في ذلك Deepgram للنسخ و OpenAI للتحليل) لتزويدك برؤى مدعومة بالذكاء الاصطناعي وتمكين جميع ميزات التطبيق.", - "tasksEmptyStateMessage": "ابدأ محادثة لإنشاء مهمة.", + "tasksEmptyStateMessage": "ستظهر هنا المهام من محادثاتك.", "clearChatAction": "مسح المحادثة", "enableApps": "تفعيل التطبيقات", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "ليس لديك Omi بعد؟", "onboardingOmiNotOrdered": "لم أطلبه بعد", "onboardingOmiOrdered": "لقد طلبته", - "onboardingPermissionTapToFix": "اضغط للإصلاح" + "onboardingPermissionTapToFix": "اضغط للإصلاح", + "consentShortMessage": "يستخدم Omi الذكاء الاصطناعي لتحويل محادثاتك إلى ملاحظات. يمكنك حذفها في أي وقت.", + "consentAiProviders": "تتم المعالجة بواسطة {providers}.", + "voiceProfileCardDismissed": "يمكنك إعداد بصمة صوتك في أي وقت من الإعدادات.", + "homeNewRecordingButton": "تسجيل جديد", + "chatWelcomeMessage": "مرحبًا، أنا Omi. اسألني عن أي شيء تحدثت عنه: ما قاله شخص ما، أو ما وعدت به، أو ملخص يومك. يمكنني أيضًا إضافة مهام وتذكّر الأشياء لك.", + "onboardingStarterTaskAsk": "اسأل Omi عن يومك", + "onboardingStarterTaskRecord": "سجّل محادثة مع Omi", + "onboardingStarterTaskCheck": "ضع علامة على هذه المهمة لإنهائها" } diff --git a/app/lib/l10n/app_be.arb b/app/lib/l10n/app_be.arb index 3b13f1b14e6..1c97280801d 100644 --- a/app/lib/l10n/app_be.arb +++ b/app/lib/l10n/app_be.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Пачніце размову, каб стварыць задачу.", + "tasksEmptyStateMessage": "Справы з вашых размоў з'явяцца тут.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Яшчэ няма Omi?", "onboardingOmiNotOrdered": "Яшчэ не замовіў", "onboardingOmiOrdered": "Я ўжо замовіў", - "onboardingPermissionTapToFix": "Націсніце, каб выправіць" + "onboardingPermissionTapToFix": "Націсніце, каб выправіць", + "consentShortMessage": "Omi з дапамогай ШІ ператварае вашы размовы ў нататкі. Іх можна выдаліць у любы момант.", + "consentAiProviders": "Апрацоўка: {providers}.", + "voiceProfileCardDismissed": "Профіль голасу заўсёды можна наладзіць у Наладах.", + "homeNewRecordingButton": "Новы запіс", + "chatWelcomeMessage": "Прывітанне, я Omi. Пытайцеся пра ўсё, пра што вы размаўлялі: што хтосьці сказаў, што вы паабяцалі, ці як прайшоў ваш дзень. Я таксама магу дадаваць задачы і запамінаць рэчы за вас.", + "onboardingStarterTaskAsk": "Спытайце Omi пра свой дзень", + "onboardingStarterTaskRecord": "Запішыце размову з Omi", + "onboardingStarterTaskCheck": "Адзначце гэтую задачу, каб завяршыць яе" } diff --git a/app/lib/l10n/app_bg.arb b/app/lib/l10n/app_bg.arb index 3b0c50c14d2..1b55486818a 100644 --- a/app/lib/l10n/app_bg.arb +++ b/app/lib/l10n/app_bg.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Продължителност: {seconds} секунди", "additionalSpeechSampleRemoved": "Допълнителната гласова проба е премахната", "consentDataMessage": "С продължаването вашите разговори, записи и лична информация ще бъдат сигурно съхранени на нашите сървъри. Вашите аудиозаписи и транскрипции се обработват от AI услуги на трети страни (включително Deepgram за транскрипция и OpenAI за анализ), за да ви предоставят прозрения, базирани на AI, и да активират всички функции на приложението.", - "tasksEmptyStateMessage": "Започнете разговор, за да създадете задача.", + "tasksEmptyStateMessage": "Задачите от разговорите ви ще се появят тук.", "clearChatAction": "Изчисти чата", "enableApps": "Активиране на приложения", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Все още нямате Omi?", "onboardingOmiNotOrdered": "Още не съм поръчал", "onboardingOmiOrdered": "Поръчал съм го", - "onboardingPermissionTapToFix": "Докоснете, за да поправите" + "onboardingPermissionTapToFix": "Докоснете, за да поправите", + "consentShortMessage": "Omi използва ИИ, за да превърне разговорите ви в бележки. Можете да ги изтриете по всяко време.", + "consentAiProviders": "Обработва се с {providers}.", + "voiceProfileCardDismissed": "Можете да настроите гласовия профил по всяко време от Настройки.", + "homeNewRecordingButton": "Нов запис", + "chatWelcomeMessage": "Здравей, аз съм Omi. Питай ме за всичко, за което си говорил: какво е казал някой, какво си обещал или обобщение на деня ти. Мога и да добавям задачи и да запомням неща вместо теб.", + "onboardingStarterTaskAsk": "Попитай Omi за деня си", + "onboardingStarterTaskRecord": "Запиши разговор с Omi", + "onboardingStarterTaskCheck": "Отметни тази задача, за да я завършиш" } diff --git a/app/lib/l10n/app_bn.arb b/app/lib/l10n/app_bn.arb index 366e8db240a..fd343256bfc 100644 --- a/app/lib/l10n/app_bn.arb +++ b/app/lib/l10n/app_bn.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "একটি কাজ তৈরি করতে কথোপকথন শুরু করুন।", + "tasksEmptyStateMessage": "আপনার কথোপকথনের করণীয়গুলো এখানে দেখা যাবে।", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "এখনও Omi নেই?", "onboardingOmiNotOrdered": "এখনও অর্ডার করিনি", "onboardingOmiOrdered": "আমি অর্ডার করেছি", - "onboardingPermissionTapToFix": "ঠিক করতে ট্যাপ করুন" + "onboardingPermissionTapToFix": "ঠিক করতে ট্যাপ করুন", + "consentShortMessage": "Omi AI ব্যবহার করে আপনার কথোপকথনকে নোটে পরিণত করে। যেকোনো সময় মুছে ফেলতে পারেন।", + "consentAiProviders": "{providers} দিয়ে প্রক্রিয়া করা হয়।", + "voiceProfileCardDismissed": "সেটিংসে যেকোনো সময় ভয়েস প্রোফাইল সেট করতে পারেন।", + "homeNewRecordingButton": "নতুন রেকর্ডিং", + "chatWelcomeMessage": "হাই, আমি Omi। আপনি যা নিয়ে কথা বলেছেন তার যেকোনো কিছু জিজ্ঞেস করুন: কেউ কী বলেছে, আপনি কী প্রতিশ্রুতি দিয়েছেন, বা আপনার দিনের সারাংশ। আমি কাজ যোগ করতে এবং আপনার জন্য জিনিস মনে রাখতেও পারি।", + "onboardingStarterTaskAsk": "Omi-কে আপনার দিন সম্পর্কে জিজ্ঞেস করুন", + "onboardingStarterTaskRecord": "Omi দিয়ে একটি কথোপকথন রেকর্ড করুন", + "onboardingStarterTaskCheck": "শেষ করতে এই কাজটিতে টিক দিন" } diff --git a/app/lib/l10n/app_bs.arb b/app/lib/l10n/app_bs.arb index f4d264ceaa7..a3c219b04b5 100644 --- a/app/lib/l10n/app_bs.arb +++ b/app/lib/l10n/app_bs.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Započnite razgovor da kreirate zadatak.", + "tasksEmptyStateMessage": "Zadaci iz vaših razgovora pojavit će se ovdje.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Još nemate Omi?", "onboardingOmiNotOrdered": "Još nisam naručio/la", "onboardingOmiOrdered": "Naručio/la sam ga", - "onboardingPermissionTapToFix": "Dodirnite da popravite" + "onboardingPermissionTapToFix": "Dodirnite da popravite", + "consentShortMessage": "Omi koristi AI da vaše razgovore pretvori u bilješke. Možete ih izbrisati bilo kada.", + "consentAiProviders": "Obrađuje se pomoću {providers}.", + "voiceProfileCardDismissed": "Glasovni profil možete podesiti bilo kada u Postavkama.", + "homeNewRecordingButton": "Novo snimanje", + "chatWelcomeMessage": "Zdravo, ja sam Omi. Pitaj me bilo šta o čemu si pričao: šta je neko rekao, šta si obećao ili sažetak tvog dana. Mogu i dodavati zadatke i pamtiti stvari za tebe.", + "onboardingStarterTaskAsk": "Pitaj Omi o svom danu", + "onboardingStarterTaskRecord": "Snimi razgovor uz Omi", + "onboardingStarterTaskCheck": "Označi ovaj zadatak da ga završiš" } diff --git a/app/lib/l10n/app_ca.arb b/app/lib/l10n/app_ca.arb index bd79dd7bbd0..484d5228f32 100644 --- a/app/lib/l10n/app_ca.arb +++ b/app/lib/l10n/app_ca.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Durada: {seconds} segons", "additionalSpeechSampleRemoved": "S'ha eliminat la mostra de veu addicional", "consentDataMessage": "Continuant, les vostres converses, enregistraments i informació personal s'emmagatzemaran de manera segura als nostres servidors. Els vostres enregistraments d'àudio i transcripcions són processats per serveis d'IA de tercers (incloent Deepgram per a la transcripció i OpenAI per a l'anàlisi) per proporcionar-vos informació impulsada per IA i habilitar totes les funcions de l'aplicació.", - "tasksEmptyStateMessage": "Inicia una conversa per crear una tasca.", + "tasksEmptyStateMessage": "Les tasques de les teves converses apareixeran aquí.", "clearChatAction": "Esborrar el xat", "enableApps": "Activar aplicacions", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Encara no tens l'Omi?", "onboardingOmiNotOrdered": "Encara no l'he demanat", "onboardingOmiOrdered": "Ja l'he demanat", - "onboardingPermissionTapToFix": "Toca per solucionar-ho" + "onboardingPermissionTapToFix": "Toca per solucionar-ho", + "consentShortMessage": "Omi fa servir la IA per convertir les teves converses en notes. Les pots esborrar quan vulguis.", + "consentAiProviders": "Processat amb {providers}.", + "voiceProfileCardDismissed": "Pots configurar el perfil de veu quan vulguis a Configuració.", + "homeNewRecordingButton": "Nova gravació", + "chatWelcomeMessage": "Hola, soc l'Omi. Pregunta'm qualsevol cosa del que has parlat: què va dir algú, què vas prometre o un resum del teu dia. També puc afegir tasques i recordar coses per tu.", + "onboardingStarterTaskAsk": "Pregunta a l'Omi pel teu dia", + "onboardingStarterTaskRecord": "Grava una conversa amb l'Omi", + "onboardingStarterTaskCheck": "Marca aquesta tasca per completar-la" } diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 2e85b0872e3..db8b59cdc8a 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Délka: {seconds} sekund", "additionalSpeechSampleRemoved": "Další hlasový vzorek byl odstraněn", "consentDataMessage": "Pokračováním budou vaše konverzace, nahrávky a osobní údaje bezpečně uloženy na našich serverech. Vaše audio nahrávky a přepisy jsou zpracovávány AI službami třetích stran (včetně Deepgram pro přepis a OpenAI pro analýzu), aby vám poskytly poznatky založené na AI a umožnily všechny funkce aplikace.", - "tasksEmptyStateMessage": "Začněte konverzaci a vytvořte úkol.", + "tasksEmptyStateMessage": "Úkoly z vašich konverzací se zobrazí tady.", "clearChatAction": "Vymazat chat", "enableApps": "Povolit aplikace", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Ještě nemáte Omi?", "onboardingOmiNotOrdered": "Ještě neobjednáno", "onboardingOmiOrdered": "Už jsem ho objednal/a", - "onboardingPermissionTapToFix": "Klepnutím opravíte" + "onboardingPermissionTapToFix": "Klepnutím opravíte", + "consentShortMessage": "Omi pomocí AI mění vaše konverzace v poznámky. Kdykoli je můžete smazat.", + "consentAiProviders": "Zpracovává {providers}.", + "voiceProfileCardDismissed": "Hlasový profil můžete kdykoli nastavit v Nastavení.", + "homeNewRecordingButton": "Nový záznam", + "chatWelcomeMessage": "Ahoj, jsem Omi. Zeptej se mě na cokoli, o čem jsi mluvil: co kdo řekl, co jsi slíbil nebo shrnutí tvého dne. Umím také přidávat úkoly a pamatovat si věci za tebe.", + "onboardingStarterTaskAsk": "Zeptej se Omi na svůj den", + "onboardingStarterTaskRecord": "Nahraj rozhovor s Omi", + "onboardingStarterTaskCheck": "Odškrtni tento úkol a dokonči ho" } diff --git a/app/lib/l10n/app_da.arb b/app/lib/l10n/app_da.arb index 482f4329bbf..63fdd27305d 100644 --- a/app/lib/l10n/app_da.arb +++ b/app/lib/l10n/app_da.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Varighed: {seconds} sekunder", "additionalSpeechSampleRemoved": "Yderligere stemmeprøve fjernet", "consentDataMessage": "Ved at fortsætte vil dine samtaler, optagelser og personlige oplysninger blive sikkert gemt på vores servere. Dine lydoptagelser og udskrifter behandles af tredjeparts AI-tjenester (herunder Deepgram til transskription og OpenAI til analyse) for at give dig AI-drevne indsigter og aktivere alle appfunktioner.", - "tasksEmptyStateMessage": "Start en samtale for at oprette en opgave.", + "tasksEmptyStateMessage": "Opgaver fra dine samtaler vises her.", "clearChatAction": "Ryd chat", "enableApps": "Aktivér apps", "omiAppName": "Omi", @@ -4157,5 +4157,13 @@ "onboardingNoOmiYet": "Har du ikke din Omi endnu?", "onboardingOmiNotOrdered": "Ikke bestilt endnu", "onboardingOmiOrdered": "Jeg har bestilt den", - "onboardingPermissionTapToFix": "Tryk for at rette" + "onboardingPermissionTapToFix": "Tryk for at rette", + "consentShortMessage": "Omi bruger AI til at gøre dine samtaler til noter. Du kan slette dem når som helst.", + "consentAiProviders": "Behandles med {providers}.", + "voiceProfileCardDismissed": "Du kan altid oprette din stemmeprofil under Indstillinger.", + "homeNewRecordingButton": "Ny optagelse", + "chatWelcomeMessage": "Hej, jeg er Omi. Spørg mig om alt, du har talt om: hvad nogen sagde, hvad du lovede, eller et overblik over din dag. Jeg kan også tilføje opgaver og huske ting for dig.", + "onboardingStarterTaskAsk": "Spørg Omi om din dag", + "onboardingStarterTaskRecord": "Optag en samtale med Omi", + "onboardingStarterTaskCheck": "Sæt flueben ved denne opgave for at fuldføre den" } diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 728faad09cb..d8ed8308eaf 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Dauer: {seconds} Sekunden", "additionalSpeechSampleRemoved": "Zusätzliche Sprachprobe entfernt", "consentDataMessage": "Durch Fortfahren werden Ihre Gespräche, Aufnahmen und persönlichen Daten sicher auf unseren Servern gespeichert. Ihre Audioaufnahmen und Transkripte werden von KI-Diensten Dritter verarbeitet (einschließlich Deepgram für die Transkription und OpenAI für die Analyse), um Ihnen KI-gestützte Erkenntnisse zu liefern und alle App-Funktionen zu ermöglichen.", - "tasksEmptyStateMessage": "Starte ein Gespräch, um eine Aufgabe zu erstellen.", + "tasksEmptyStateMessage": "To-dos aus deinen Gesprächen erscheinen hier.", "clearChatAction": "Chat löschen", "enableApps": "Apps aktivieren", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Hast du dein Omi noch nicht?", "onboardingOmiNotOrdered": "Noch nicht bestellt", "onboardingOmiOrdered": "Ich habe es bestellt", - "onboardingPermissionTapToFix": "Zum Beheben tippen" + "onboardingPermissionTapToFix": "Zum Beheben tippen", + "consentShortMessage": "Omi macht mit KI aus deinen Gesprächen Notizen. Du kannst sie jederzeit löschen.", + "consentAiProviders": "Verarbeitet mit {providers}.", + "voiceProfileCardDismissed": "Dein Stimmprofil kannst du jederzeit in den Einstellungen einrichten.", + "homeNewRecordingButton": "Neue Aufnahme", + "chatWelcomeMessage": "Hi, ich bin Omi. Frag mich alles, worüber du gesprochen hast: was jemand gesagt hat, was du versprochen hast oder eine Zusammenfassung deines Tages. Ich kann auch Aufgaben hinzufügen und mir Dinge für dich merken.", + "onboardingStarterTaskAsk": "Frag Omi nach deinem Tag", + "onboardingStarterTaskRecord": "Nimm ein Gespräch mit Omi auf", + "onboardingStarterTaskCheck": "Hake diese Aufgabe ab, um sie zu erledigen" } diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 960b3f99425..a1ce91e0a25 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Διάρκεια: {seconds} δευτερόλεπτα", "additionalSpeechSampleRemoved": "Το πρόσθετο δείγμα ομιλίας αφαιρέθηκε", "consentDataMessage": "Συνεχίζοντας, οι συνομιλίες, οι εγγραφές και τα προσωπικά σας στοιχεία θα αποθηκευτούν με ασφάλεια στους διακομιστές μας. Οι ηχογραφήσεις και τα μεταγραφές σας επεξεργάζονται από υπηρεσίες τεχνητής νοημοσύνης τρίτων (συμπεριλαμβανομένων των Deepgram για μεταγραφή και OpenAI για ανάλυση) για να σας παρέχουν γνώσεις βασισμένες σε AI και να ενεργοποιήσουν όλες τις λειτουργίες της εφαρμογής.", - "tasksEmptyStateMessage": "Ξεκινήστε μια συνομιλία για να δημιουργήσετε μια εργασία.", + "tasksEmptyStateMessage": "Οι εργασίες από τις συζητήσεις σας θα εμφανίζονται εδώ.", "clearChatAction": "Διαγραφή συνομιλίας", "enableApps": "Ενεργοποίηση εφαρμογών", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Δεν έχετε ακόμα το Omi σας;", "onboardingOmiNotOrdered": "Δεν το έχω παραγγείλει ακόμα", "onboardingOmiOrdered": "Το έχω παραγγείλει", - "onboardingPermissionTapToFix": "Πατήστε για διόρθωση" + "onboardingPermissionTapToFix": "Πατήστε για διόρθωση", + "consentShortMessage": "Το Omi χρησιμοποιεί AI για να μετατρέπει τις συνομιλίες σου σε σημειώσεις. Μπορείς να τις διαγράψεις οποιαδήποτε στιγμή.", + "consentAiProviders": "Επεξεργασία μέσω {providers}.", + "voiceProfileCardDismissed": "Μπορείτε να ρυθμίσετε το προφίλ φωνής ανά πάσα στιγμή από τις Ρυθμίσεις.", + "homeNewRecordingButton": "Νέα ηχογράφηση", + "chatWelcomeMessage": "Γεια, είμαι το Omi. Ρώτα με οτιδήποτε έχεις συζητήσει: τι είπε κάποιος, τι υποσχέθηκες ή μια σύνοψη της μέρας σου. Μπορώ επίσης να προσθέτω εργασίες και να θυμάμαι πράγματα για σένα.", + "onboardingStarterTaskAsk": "Ρώτα το Omi για τη μέρα σου", + "onboardingStarterTaskRecord": "Ηχογράφησε μια συζήτηση με το Omi", + "onboardingStarterTaskCheck": "Σημείωσε αυτή την εργασία για να την ολοκληρώσεις" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 6fc67cd1d86..c1da6a97b5c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -5829,7 +5829,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Start a conversation to create a task.", + "tasksEmptyStateMessage": "To-dos from your conversations will show up here.", "@tasksEmptyStateMessage": { "description": "Guidance on the empty Tasks tab: start a conversation to create a task." }, @@ -16132,5 +16132,43 @@ "onboardingPermissionTapToFix": "Tap to fix", "@onboardingPermissionTapToFix": { "description": "Onboarding permissions: shown on a denied permission card; tapping opens Settings" + }, + "consentShortMessage": "Omi uses AI to turn your conversations into notes. You can delete them anytime.", + "@consentShortMessage": { + "description": "Onboarding data & privacy consent: one-line summary of storage and AI processing" + }, + "consentAiProviders": "Processed with {providers}.", + "@consentAiProviders": { + "description": "Onboarding consent: names the third-party AI providers (brand names, not translated)", + "placeholders": { + "providers": { + "type": "String", + "example": "Deepgram, OpenAI" + } + } + }, + "voiceProfileCardDismissed": "You can set up Voice Profile anytime in Settings.", + "@voiceProfileCardDismissed": { + "description": "Toast after dismissing the Teach Omi your voice card on Home; points to Settings > Voice Profile." + }, + "homeNewRecordingButton": "New Recording", + "@homeNewRecordingButton": { + "description": "Label on Home's main bottom button that starts a new recording with the phone." + }, + "chatWelcomeMessage": "Hi, I'm Omi. Ask me anything you've talked about: what someone said, what you promised, or a summary of your day. I can also add to-dos and remember things for you.", + "@chatWelcomeMessage": { + "description": "First message Omi shows in an empty chat the first time it is opened, explaining what chat can do" + }, + "onboardingStarterTaskAsk": "Ask Omi about your day", + "@onboardingStarterTaskAsk": { + "description": "Starter task added to an empty task list after onboarding: try chatting with Omi" + }, + "onboardingStarterTaskRecord": "Record a conversation with Omi", + "@onboardingStarterTaskRecord": { + "description": "Starter task added to an empty task list after onboarding: try recording" + }, + "onboardingStarterTaskCheck": "Check this off to finish a task", + "@onboardingStarterTaskCheck": { + "description": "Starter task added to an empty task list after onboarding: shows how completing a task works" } } diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 98034d0e0f0..9d4ecad8bb3 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Duración: {seconds} segundos", "additionalSpeechSampleRemoved": "Muestra de voz adicional eliminada", "consentDataMessage": "Al continuar, tus conversaciones, grabaciones e información personal se almacenarán de forma segura en nuestros servidores. Tus grabaciones de audio y transcripciones son procesadas por servicios de IA de terceros (incluyendo Deepgram para transcripción y OpenAI para análisis) para proporcionarte información impulsada por IA y habilitar todas las funciones de la aplicación.", - "tasksEmptyStateMessage": "Inicia una conversación para crear una tarea.", + "tasksEmptyStateMessage": "Las tareas de tus conversaciones aparecerán aquí.", "clearChatAction": "Borrar chat", "enableApps": "Habilitar apps", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "¿Aún no tienes tu Omi?", "onboardingOmiNotOrdered": "Aún no lo he pedido", "onboardingOmiOrdered": "Ya lo pedí", - "onboardingPermissionTapToFix": "Toca para solucionarlo" + "onboardingPermissionTapToFix": "Toca para solucionarlo", + "consentShortMessage": "Omi usa IA para convertir tus conversaciones en notas. Puedes eliminarlas cuando quieras.", + "consentAiProviders": "Procesado con {providers}.", + "voiceProfileCardDismissed": "Puedes configurar tu perfil de voz cuando quieras en Ajustes.", + "homeNewRecordingButton": "Nueva grabación", + "chatWelcomeMessage": "Hola, soy Omi. Pregúntame lo que quieras sobre lo que has hablado: lo que dijo alguien, lo que prometiste o un resumen de tu día. También puedo añadir tareas y recordar cosas por ti.", + "onboardingStarterTaskAsk": "Pregúntale a Omi por tu día", + "onboardingStarterTaskRecord": "Graba una conversación con Omi", + "onboardingStarterTaskCheck": "Marca esta tarea para completarla" } diff --git a/app/lib/l10n/app_et.arb b/app/lib/l10n/app_et.arb index bd72644076c..52560e7dee8 100644 --- a/app/lib/l10n/app_et.arb +++ b/app/lib/l10n/app_et.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Kestus: {seconds} sekundit", "additionalSpeechSampleRemoved": "Lisakõnenäidis eemaldatud", "consentDataMessage": "Jätkates salvestatakse teie vestlused, salvestised ja isikuandmed turvaliselt meie serverites. Teie helisalvestisi ja transkriptsioone töötlevad kolmandate osapoolte AI-teenused (sealhulgas Deepgram transkriptsiooni ja OpenAI analüüsi jaoks), et pakkuda teile AI-põhiseid ülevaateid ja võimaldada kõiki rakenduse funktsioone.", - "tasksEmptyStateMessage": "Ülesande loomiseks alusta vestlust.", + "tasksEmptyStateMessage": "Sinu vestlustest pärit ülesanded ilmuvad siia.", "clearChatAction": "Tühjenda vestlus", "enableApps": "Luba rakendused", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Sul pole veel Omit?", "onboardingOmiNotOrdered": "Pole veel tellinud", "onboardingOmiOrdered": "Olen selle tellinud", - "onboardingPermissionTapToFix": "Puuduta parandamiseks" + "onboardingPermissionTapToFix": "Puuduta parandamiseks", + "consentShortMessage": "Omi muudab tehisintellekti abil sinu vestlused märkmeteks. Saad need igal ajal kustutada.", + "consentAiProviders": "Töödeldakse teenustega {providers}.", + "voiceProfileCardDismissed": "Häälprofiili saad seadistada igal ajal seadetes.", + "homeNewRecordingButton": "Uus salvestus", + "chatWelcomeMessage": "Tere, mina olen Omi. Küsi minult kõike, millest oled rääkinud: mida keegi ütles, mida sa lubasid või kokkuvõtet oma päevast. Saan ka ülesandeid lisada ja asju sinu eest meeles pidada.", + "onboardingStarterTaskAsk": "Küsi Omilt oma päeva kohta", + "onboardingStarterTaskRecord": "Salvesta Omiga üks vestlus", + "onboardingStarterTaskCheck": "Märgi see ülesanne tehtuks" } diff --git a/app/lib/l10n/app_fa.arb b/app/lib/l10n/app_fa.arb index f73a1c73ccd..2a6b645cf61 100644 --- a/app/lib/l10n/app_fa.arb +++ b/app/lib/l10n/app_fa.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "برای ایجاد یک کار، گفتگو را شروع کنید.", + "tasksEmptyStateMessage": "کارهای گفتگوهای شما اینجا نمایش داده می‌شوند.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "هنوز Omi ندارید؟", "onboardingOmiNotOrdered": "هنوز سفارش نداده‌ام", "onboardingOmiOrdered": "سفارش داده‌ام", - "onboardingPermissionTapToFix": "برای رفع ضربه بزنید" + "onboardingPermissionTapToFix": "برای رفع ضربه بزنید", + "consentShortMessage": "Omi با هوش مصنوعی گفتگوهای شما را به یادداشت تبدیل می‌کند. هر زمان بخواهید می‌توانید آن‌ها را حذف کنید.", + "consentAiProviders": "پردازش با {providers}.", + "voiceProfileCardDismissed": "هر زمان می‌توانید نمایه صدا را در تنظیمات راه‌اندازی کنید.", + "homeNewRecordingButton": "ضبط جدید", + "chatWelcomeMessage": "سلام، من Omi هستم. هر چیزی درباره صحبت‌هایت بپرس: اینکه کسی چه گفت، چه قولی دادی یا خلاصه‌ای از روزت. می‌توانم کار هم اضافه کنم و چیزها را برایت به خاطر بسپارم.", + "onboardingStarterTaskAsk": "از Omi درباره روزت بپرس", + "onboardingStarterTaskRecord": "یک گفتگو را با Omi ضبط کن", + "onboardingStarterTaskCheck": "این کار را تیک بزن تا تمام شود" } diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index be877d6d183..ce0a437dba4 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Kesto: {seconds} sekuntia", "additionalSpeechSampleRemoved": "Lisäpuhenäyte poistettu", "consentDataMessage": "Jatkamalla keskustelusi, tallenteet ja henkilötietosi tallennetaan turvallisesti palvelimillemme. Äänitallenteitasi ja transkriptioitasi käsittelevät kolmannen osapuolen tekoälypalvelut (mukaan lukien Deepgram transkriptiota ja OpenAI analyysiä varten) tarjotaksemme sinulle tekoälypohjaisia oivalluksia ja mahdollistaaksemme kaikki sovelluksen ominaisuudet.", - "tasksEmptyStateMessage": "Aloita keskustelu luodaksesi tehtävän.", + "tasksEmptyStateMessage": "Keskusteluistasi poimitut tehtävät näkyvät täällä.", "clearChatAction": "Tyhjennä keskustelu", "enableApps": "Ota sovellukset käyttöön", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Eikö Omi ole vielä saapunut?", "onboardingOmiNotOrdered": "En ole vielä tilannut", "onboardingOmiOrdered": "Olen tilannut sen", - "onboardingPermissionTapToFix": "Korjaa napauttamalla" + "onboardingPermissionTapToFix": "Korjaa napauttamalla", + "consentShortMessage": "Omi tekee keskusteluistasi muistiinpanoja tekoälyn avulla. Voit poistaa ne milloin tahansa.", + "consentAiProviders": "Käsittely: {providers}.", + "voiceProfileCardDismissed": "Voit määrittää ääniprofiilin milloin tahansa asetuksissa.", + "homeNewRecordingButton": "Uusi tallennus", + "chatWelcomeMessage": "Hei, olen Omi. Kysy minulta mitä tahansa siitä, mistä olet puhunut: mitä joku sanoi, mitä lupasit tai yhteenveto päivästäsi. Voin myös lisätä tehtäviä ja muistaa asioita puolestasi.", + "onboardingStarterTaskAsk": "Kysy Omilta päivästäsi", + "onboardingStarterTaskRecord": "Tallenna keskustelu Omilla", + "onboardingStarterTaskCheck": "Merkitse tämä tehtävä tehdyksi" } diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 1d8ddd85c41..6c06ad12e28 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Durée : {seconds} secondes", "additionalSpeechSampleRemoved": "Échantillon vocal supplémentaire supprimé", "consentDataMessage": "En continuant, vos conversations, enregistrements et informations personnelles seront stockés en toute sécurité sur nos serveurs. Vos enregistrements audio et transcriptions sont traités par des services d'IA tiers (notamment Deepgram pour la transcription et OpenAI pour l'analyse) afin de vous fournir des informations alimentées par l'IA et d'activer toutes les fonctionnalités de l'application.", - "tasksEmptyStateMessage": "Démarrez une conversation pour créer une tâche.", + "tasksEmptyStateMessage": "Les tâches issues de vos conversations apparaîtront ici.", "clearChatAction": "Effacer le chat", "enableApps": "Activer les applications", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Vous n'avez pas encore votre Omi ?", "onboardingOmiNotOrdered": "Pas encore commandé", "onboardingOmiOrdered": "Je l'ai commandé", - "onboardingPermissionTapToFix": "Touchez pour corriger" + "onboardingPermissionTapToFix": "Touchez pour corriger", + "consentShortMessage": "Omi utilise l'IA pour transformer vos conversations en notes. Vous pouvez les supprimer à tout moment.", + "consentAiProviders": "Traité avec {providers}.", + "voiceProfileCardDismissed": "Vous pouvez configurer votre profil vocal à tout moment dans les Réglages.", + "homeNewRecordingButton": "Nouvel enregistrement", + "chatWelcomeMessage": "Salut, je suis Omi. Demandez-moi tout ce dont vous avez parlé : ce que quelqu'un a dit, ce que vous avez promis ou un résumé de votre journée. Je peux aussi ajouter des tâches et retenir des choses pour vous.", + "onboardingStarterTaskAsk": "Demandez à Omi comment s'est passée votre journée", + "onboardingStarterTaskRecord": "Enregistrez une conversation avec Omi", + "onboardingStarterTaskCheck": "Cochez cette tâche pour la terminer" } diff --git a/app/lib/l10n/app_he.arb b/app/lib/l10n/app_he.arb index 7207dd4ed40..abfeb088314 100644 --- a/app/lib/l10n/app_he.arb +++ b/app/lib/l10n/app_he.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "התחילו שיחה כדי ליצור משימה.", + "tasksEmptyStateMessage": "משימות מהשיחות שלך יופיעו כאן.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "עדיין אין לכם Omi?", "onboardingOmiNotOrdered": "עוד לא הזמנתי", "onboardingOmiOrdered": "הזמנתי", - "onboardingPermissionTapToFix": "הקישו לתיקון" + "onboardingPermissionTapToFix": "הקישו לתיקון", + "consentShortMessage": "Omi משתמש בבינה מלאכותית כדי להפוך את השיחות שלך להערות. אפשר למחוק אותן בכל עת.", + "consentAiProviders": "מעובד באמצעות {providers}.", + "voiceProfileCardDismissed": "אפשר להגדיר פרופיל קולי בכל עת בהגדרות.", + "homeNewRecordingButton": "הקלטה חדשה", + "chatWelcomeMessage": "היי, אני Omi. שאל אותי כל דבר על מה שדיברת: מה מישהו אמר, מה הבטחת או סיכום של היום שלך. אני יכול גם להוסיף משימות ולזכור דברים בשבילך.", + "onboardingStarterTaskAsk": "שאל את Omi על היום שלך", + "onboardingStarterTaskRecord": "הקלט שיחה עם Omi", + "onboardingStarterTaskCheck": "סמן משימה זו כדי לסיים אותה" } diff --git a/app/lib/l10n/app_hi.arb b/app/lib/l10n/app_hi.arb index d9c9f5fdcb8..465ddd30d49 100644 --- a/app/lib/l10n/app_hi.arb +++ b/app/lib/l10n/app_hi.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "अवधि: {seconds} सेकंड", "additionalSpeechSampleRemoved": "अतिरिक्त भाषण नमूना हटाया गया", "consentDataMessage": "जारी रखने पर, आपकी बातचीत, रिकॉर्डिंग और व्यक्तिगत जानकारी हमारे सर्वर पर सुरक्षित रूप से संग्रहीत की जाएगी। आपकी ऑडियो रिकॉर्डिंग और ट्रांसक्रिप्ट तीसरे पक्ष की AI सेवाओं (ट्रांसक्रिप्शन के लिए Deepgram और विश्लेषण के लिए OpenAI सहित) द्वारा संसाधित किए जाते हैं ताकि आपको AI-संचालित अंतर्दृष्टि प्रदान की जा सके और सभी ऐप सुविधाएँ सक्षम की जा सकें।", - "tasksEmptyStateMessage": "टास्क बनाने के लिए बातचीत शुरू करें।", + "tasksEmptyStateMessage": "आपकी बातचीत से निकले काम यहाँ दिखेंगे।", "clearChatAction": "चैट साफ़ करें", "enableApps": "ऐप्स सक्षम करें", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "अभी तक आपका Omi नहीं आया?", "onboardingOmiNotOrdered": "अभी ऑर्डर नहीं किया", "onboardingOmiOrdered": "मैंने ऑर्डर कर दिया है", - "onboardingPermissionTapToFix": "ठीक करने के लिए टैप करें" + "onboardingPermissionTapToFix": "ठीक करने के लिए टैप करें", + "consentShortMessage": "Omi AI की मदद से आपकी बातचीत को नोट्स में बदलता है। आप उन्हें कभी भी हटा सकते हैं।", + "consentAiProviders": "{providers} के साथ प्रोसेस किया जाता है।", + "voiceProfileCardDismissed": "आप सेटिंग्स में कभी भी वॉइस प्रोफ़ाइल सेट कर सकते हैं।", + "homeNewRecordingButton": "नई रिकॉर्डिंग", + "chatWelcomeMessage": "नमस्ते, मैं Omi हूँ। आपने जिस बारे में बात की है, उसके बारे में कुछ भी पूछें: किसी ने क्या कहा, आपने क्या वादा किया, या आपके दिन का सारांश। मैं काम जोड़ सकता हूँ और आपके लिए चीज़ें याद भी रख सकता हूँ।", + "onboardingStarterTaskAsk": "Omi से अपने दिन के बारे में पूछें", + "onboardingStarterTaskRecord": "Omi से एक बातचीत रिकॉर्ड करें", + "onboardingStarterTaskCheck": "इस काम को पूरा करने के लिए टिक करें" } diff --git a/app/lib/l10n/app_hr.arb b/app/lib/l10n/app_hr.arb index a8c2471e9c1..9d3ddd92f7d 100644 --- a/app/lib/l10n/app_hr.arb +++ b/app/lib/l10n/app_hr.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Započnite razgovor da biste stvorili zadatak.", + "tasksEmptyStateMessage": "Zadaci iz vaših razgovora pojavit će se ovdje.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Još nemate Omi?", "onboardingOmiNotOrdered": "Još nisam naručio/la", "onboardingOmiOrdered": "Naručio/la sam ga", - "onboardingPermissionTapToFix": "Dodirnite za popravak" + "onboardingPermissionTapToFix": "Dodirnite za popravak", + "consentShortMessage": "Omi koristi AI kako bi vaše razgovore pretvorio u bilješke. Možete ih izbrisati bilo kada.", + "consentAiProviders": "Obrađuje se pomoću {providers}.", + "voiceProfileCardDismissed": "Glasovni profil možete postaviti bilo kada u Postavkama.", + "homeNewRecordingButton": "Novo snimanje", + "chatWelcomeMessage": "Bok, ja sam Omi. Pitaj me bilo što o čemu si razgovarao: što je netko rekao, što si obećao ili sažetak tvog dana. Mogu i dodavati zadatke i pamtiti stvari za tebe.", + "onboardingStarterTaskAsk": "Pitaj Omi o svom danu", + "onboardingStarterTaskRecord": "Snimi razgovor uz Omi", + "onboardingStarterTaskCheck": "Označi ovaj zadatak da ga završiš" } diff --git a/app/lib/l10n/app_hu.arb b/app/lib/l10n/app_hu.arb index 67d73e2d3b6..7c824d9980b 100644 --- a/app/lib/l10n/app_hu.arb +++ b/app/lib/l10n/app_hu.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Időtartam: {seconds} másodperc", "additionalSpeechSampleRemoved": "További hangminta eltávolítva", "consentDataMessage": "A folytatással beszélgetéseit, felvételeit és személyes adatait biztonságosan tároljuk szervereinken. Hangfelvételeit és átiratait harmadik féltől származó AI szolgáltatások dolgozzák fel (beleértve a Deepgramot az átíráshoz és az OpenAI-t az elemzéshez), hogy AI-alapú betekintéseket nyújtsunk Önnek és az alkalmazás összes funkcióját biztosítsuk.", - "tasksEmptyStateMessage": "Feladat létrehozásához kezdj beszélgetést.", + "tasksEmptyStateMessage": "A beszélgetéseidből származó teendők itt jelennek meg.", "clearChatAction": "Chat törlése", "enableApps": "Alkalmazások engedélyezése", "omiAppName": "Omi", @@ -4211,5 +4211,13 @@ "onboardingNoOmiYet": "Még nincs meg az Omid?", "onboardingOmiNotOrdered": "Még nem rendeltem", "onboardingOmiOrdered": "Már megrendeltem", - "onboardingPermissionTapToFix": "Koppints a javításhoz" + "onboardingPermissionTapToFix": "Koppints a javításhoz", + "consentShortMessage": "Az Omi mesterséges intelligenciával jegyzetekké alakítja a beszélgetéseidet. Bármikor törölheted őket.", + "consentAiProviders": "Feldolgozás: {providers}.", + "voiceProfileCardDismissed": "A hangprofilt bármikor beállíthatod a Beállításokban.", + "homeNewRecordingButton": "Új felvétel", + "chatWelcomeMessage": "Szia, Omi vagyok. Kérdezz bármit arról, amiről beszéltél: mit mondott valaki, mit ígértél, vagy kérj összefoglalót a napodról. Feladatokat is fel tudok venni, és dolgokat is megjegyzek helyetted.", + "onboardingStarterTaskAsk": "Kérdezd meg Omit a napodról", + "onboardingStarterTaskRecord": "Rögzíts egy beszélgetést az Omival", + "onboardingStarterTaskCheck": "Pipáld ki ezt a feladatot" } diff --git a/app/lib/l10n/app_id.arb b/app/lib/l10n/app_id.arb index 9380e371c6c..24a6a240948 100644 --- a/app/lib/l10n/app_id.arb +++ b/app/lib/l10n/app_id.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Durasi: {seconds} detik", "additionalSpeechSampleRemoved": "Sampel suara tambahan dihapus", "consentDataMessage": "Dengan melanjutkan, percakapan, rekaman, dan informasi pribadi Anda akan disimpan dengan aman di server kami. Rekaman audio dan transkrip Anda diproses oleh layanan AI pihak ketiga (termasuk Deepgram untuk transkripsi dan OpenAI untuk analisis) untuk memberikan Anda wawasan berbasis AI dan mengaktifkan semua fitur aplikasi.", - "tasksEmptyStateMessage": "Mulai percakapan untuk membuat tugas.", + "tasksEmptyStateMessage": "Tugas dari percakapan Anda akan muncul di sini.", "clearChatAction": "Hapus obrolan", "enableApps": "Aktifkan aplikasi", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Belum punya Omi?", "onboardingOmiNotOrdered": "Belum memesan", "onboardingOmiOrdered": "Saya sudah memesannya", - "onboardingPermissionTapToFix": "Ketuk untuk memperbaiki" + "onboardingPermissionTapToFix": "Ketuk untuk memperbaiki", + "consentShortMessage": "Omi menggunakan AI untuk mengubah percakapanmu menjadi catatan. Kamu bisa menghapusnya kapan saja.", + "consentAiProviders": "Diproses dengan {providers}.", + "voiceProfileCardDismissed": "Anda dapat mengatur Profil Suara kapan saja di Pengaturan.", + "homeNewRecordingButton": "Rekaman Baru", + "chatWelcomeMessage": "Hai, aku Omi. Tanyakan apa saja tentang hal yang kamu bicarakan: apa yang dikatakan seseorang, apa yang kamu janjikan, atau ringkasan harimu. Aku juga bisa menambahkan tugas dan mengingat hal-hal untukmu.", + "onboardingStarterTaskAsk": "Tanyakan harimu kepada Omi", + "onboardingStarterTaskRecord": "Rekam percakapan dengan Omi", + "onboardingStarterTaskCheck": "Centang tugas ini untuk menyelesaikannya" } diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index f9866f112bc..07c0889fd31 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Durata: {seconds} secondi", "additionalSpeechSampleRemoved": "Campione vocale aggiuntivo rimosso", "consentDataMessage": "Continuando, le tue conversazioni, registrazioni e informazioni personali saranno archiviate in modo sicuro sui nostri server. Le tue registrazioni audio e trascrizioni vengono elaborate da servizi AI di terze parti (inclusi Deepgram per la trascrizione e OpenAI per l'analisi) per fornirti approfondimenti basati sull'AI e abilitare tutte le funzionalità dell'app.", - "tasksEmptyStateMessage": "Avvia una conversazione per creare un'attività.", + "tasksEmptyStateMessage": "Le attività dalle tue conversazioni appariranno qui.", "clearChatAction": "Cancella chat", "enableApps": "Abilita app", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Non hai ancora il tuo Omi?", "onboardingOmiNotOrdered": "Non ancora ordinato", "onboardingOmiOrdered": "L'ho ordinato", - "onboardingPermissionTapToFix": "Tocca per risolvere" + "onboardingPermissionTapToFix": "Tocca per risolvere", + "consentShortMessage": "Omi usa l'IA per trasformare le tue conversazioni in note. Puoi eliminarle quando vuoi.", + "consentAiProviders": "Elaborato con {providers}.", + "voiceProfileCardDismissed": "Puoi configurare il profilo vocale in qualsiasi momento nelle Impostazioni.", + "homeNewRecordingButton": "Nuova registrazione", + "chatWelcomeMessage": "Ciao, sono Omi. Chiedimi qualsiasi cosa di cui hai parlato: cosa ha detto qualcuno, cosa hai promesso o un riepilogo della tua giornata. Posso anche aggiungere attività e ricordare cose per te.", + "onboardingStarterTaskAsk": "Chiedi a Omi della tua giornata", + "onboardingStarterTaskRecord": "Registra una conversazione con Omi", + "onboardingStarterTaskCheck": "Spunta questa attività per completarla" } diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 1167fbf75b1..2c5138265cf 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -1502,7 +1502,7 @@ "durationSeconds": "長さ: {seconds} 秒", "additionalSpeechSampleRemoved": "追加の音声サンプルを削除しました", "consentDataMessage": "続行することで、会話、録音、個人情報は安全に当社のサーバーに保存されます。音声録音と文字起こしは、サードパーティのAIサービス(文字起こし用のDeepgramと分析用のOpenAIを含む)によって処理され、AI駆動のインサイトを提供し、すべてのアプリ機能を有効にします。", - "tasksEmptyStateMessage": "会話を始めてタスクを作成しましょう。", + "tasksEmptyStateMessage": "会話から見つかったToDoがここに表示されます。", "clearChatAction": "チャットを消去", "enableApps": "アプリを有効化", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "まだOmiをお持ちでないですか?", "onboardingOmiNotOrdered": "まだ注文していません", "onboardingOmiOrdered": "注文済みです", - "onboardingPermissionTapToFix": "タップして修正" + "onboardingPermissionTapToFix": "タップして修正", + "consentShortMessage": "OmiはAIで会話をメモにまとめます。メモはいつでも削除できます。", + "consentAiProviders": "{providers} で処理されます。", + "voiceProfileCardDismissed": "ボイスプロフィールは設定からいつでも設定できます。", + "homeNewRecordingButton": "新規録音", + "chatWelcomeMessage": "こんにちは、Omiです。話した内容なら何でも聞いてください。誰が何を言ったか、何を約束したか、一日のまとめなど。タスクの追加や、覚えておくこともできます。", + "onboardingStarterTaskAsk": "Omiに今日のことを聞いてみる", + "onboardingStarterTaskRecord": "Omiで会話を録音する", + "onboardingStarterTaskCheck": "このタスクにチェックして完了する" } diff --git a/app/lib/l10n/app_kn.arb b/app/lib/l10n/app_kn.arb index 6b0bcd97891..964e5f376e7 100644 --- a/app/lib/l10n/app_kn.arb +++ b/app/lib/l10n/app_kn.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "ಕಾರ್ಯವನ್ನು ರಚಿಸಲು ಸಂಭಾಷಣೆಯನ್ನು ಪ್ರಾರಂಭಿಸಿ.", + "tasksEmptyStateMessage": "ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳ ಕಾರ್ಯಗಳು ಇಲ್ಲಿ ಕಾಣಿಸುತ್ತವೆ.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "ಇನ್ನೂ ನಿಮ್ಮ Omi ಇಲ್ಲವೇ?", "onboardingOmiNotOrdered": "ಇನ್ನೂ ಆರ್ಡರ್ ಮಾಡಿಲ್ಲ", "onboardingOmiOrdered": "ನಾನು ಆರ್ಡರ್ ಮಾಡಿದ್ದೇನೆ", - "onboardingPermissionTapToFix": "ಸರಿಪಡಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ" + "onboardingPermissionTapToFix": "ಸರಿಪಡಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ", + "consentShortMessage": "Omi AI ಬಳಸಿ ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳನ್ನು ಟಿಪ್ಪಣಿಗಳಾಗಿ ಮಾಡುತ್ತದೆ. ಯಾವಾಗ ಬೇಕಾದರೂ ಅಳಿಸಬಹುದು.", + "consentAiProviders": "{providers} ಮೂಲಕ ಸಂಸ್ಕರಿಸಲಾಗುತ್ತದೆ.", + "voiceProfileCardDismissed": "ಸೆಟ್ಟಿಂಗ್‌ಗಳಲ್ಲಿ ಯಾವಾಗ ಬೇಕಾದರೂ ಧ್ವನಿ ಪ್ರೊಫೈಲ್ ಹೊಂದಿಸಬಹುದು.", + "homeNewRecordingButton": "ಹೊಸ ರೆಕಾರ್ಡಿಂಗ್", + "chatWelcomeMessage": "ನಮಸ್ಕಾರ, ನಾನು Omi. ನೀವು ಮಾತನಾಡಿದ ಯಾವುದರ ಬಗ್ಗೆಯೂ ಕೇಳಿ: ಯಾರು ಏನು ಹೇಳಿದರು, ನೀವು ಏನು ಭರವಸೆ ನೀಡಿದ್ದೀರಿ ಅಥವಾ ನಿಮ್ಮ ದಿನದ ಸಾರಾಂಶ. ನಾನು ಕಾರ್ಯಗಳನ್ನು ಸೇರಿಸಬಹುದು ಮತ್ತು ನಿಮಗಾಗಿ ವಿಷಯಗಳನ್ನು ನೆನಪಿಟ್ಟುಕೊಳ್ಳಬಹುದು.", + "onboardingStarterTaskAsk": "ನಿಮ್ಮ ದಿನದ ಬಗ್ಗೆ Omi ಅನ್ನು ಕೇಳಿ", + "onboardingStarterTaskRecord": "Omi ಮೂಲಕ ಒಂದು ಸಂಭಾಷಣೆಯನ್ನು ರೆಕಾರ್ಡ್ ಮಾಡಿ", + "onboardingStarterTaskCheck": "ಮುಗಿಸಲು ಈ ಕಾರ್ಯವನ್ನು ಟಿಕ್ ಮಾಡಿ" } diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 024722c8681..a0b9af2ebbc 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "길이: {seconds}초", "additionalSpeechSampleRemoved": "추가 음성 샘플이 삭제되었습니다", "consentDataMessage": "계속하면 대화, 녹음 및 개인 정보가 서버에 안전하게 저장됩니다. 오디오 녹음 및 텍스트 변환은 제3자 AI 서비스(전사를 위한 Deepgram 및 분석을 위한 OpenAI 포함)에 의해 처리되어 AI 기반 인사이트를 제공하고 모든 앱 기능을 활성화합니다.", - "tasksEmptyStateMessage": "대화를 시작하여 할 일을 만드세요.", + "tasksEmptyStateMessage": "대화에서 나온 할 일이 여기에 표시돼요.", "clearChatAction": "채팅 삭제", "enableApps": "앱 활성화", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "아직 Omi가 없으신가요?", "onboardingOmiNotOrdered": "아직 주문하지 않았어요", "onboardingOmiOrdered": "주문했어요", - "onboardingPermissionTapToFix": "탭하여 해결" + "onboardingPermissionTapToFix": "탭하여 해결", + "consentShortMessage": "Omi는 AI로 대화를 메모로 정리합니다. 언제든지 삭제할 수 있습니다.", + "consentAiProviders": "{providers}(으)로 처리됩니다.", + "voiceProfileCardDismissed": "음성 프로필은 설정에서 언제든지 설정할 수 있어요.", + "homeNewRecordingButton": "새 녹음", + "chatWelcomeMessage": "안녕하세요, Omi예요. 나눈 대화라면 무엇이든 물어보세요. 누가 무슨 말을 했는지, 무엇을 약속했는지, 하루 요약까지요. 할 일을 추가하고 기억해 둘 수도 있어요.", + "onboardingStarterTaskAsk": "Omi에게 오늘 하루에 대해 물어보기", + "onboardingStarterTaskRecord": "Omi로 대화 녹음하기", + "onboardingStarterTaskCheck": "이 할 일을 체크해서 완료하기" } diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index b8b72d60f61..0658fbc61e3 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -9306,7 +9306,7 @@ abstract class AppLocalizations { /// Guidance on the empty Tasks tab: start a conversation to create a task. /// /// In en, this message translates to: - /// **'Start a conversation to create a task.'** + /// **'To-dos from your conversations will show up here.'** String get tasksEmptyStateMessage; /// Menu item text for clearing chat history @@ -23882,6 +23882,54 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Tap to fix'** String get onboardingPermissionTapToFix; + + /// Onboarding data & privacy consent: one-line summary of storage and AI processing + /// + /// In en, this message translates to: + /// **'Omi uses AI to turn your conversations into notes. You can delete them anytime.'** + String get consentShortMessage; + + /// Onboarding consent: names the third-party AI providers (brand names, not translated) + /// + /// In en, this message translates to: + /// **'Processed with {providers}.'** + String consentAiProviders(String providers); + + /// Toast after dismissing the Teach Omi your voice card on Home; points to Settings > Voice Profile. + /// + /// In en, this message translates to: + /// **'You can set up Voice Profile anytime in Settings.'** + String get voiceProfileCardDismissed; + + /// Label on Home's main bottom button that starts a new recording with the phone. + /// + /// In en, this message translates to: + /// **'New Recording'** + String get homeNewRecordingButton; + + /// First message Omi shows in an empty chat the first time it is opened, explaining what chat can do + /// + /// In en, this message translates to: + /// **'Hi, I\'m Omi. Ask me anything you\'ve talked about: what someone said, what you promised, or a summary of your day. I can also add to-dos and remember things for you.'** + String get chatWelcomeMessage; + + /// Starter task added to an empty task list after onboarding: try chatting with Omi + /// + /// In en, this message translates to: + /// **'Ask Omi about your day'** + String get onboardingStarterTaskAsk; + + /// Starter task added to an empty task list after onboarding: try recording + /// + /// In en, this message translates to: + /// **'Record a conversation with Omi'** + String get onboardingStarterTaskRecord; + + /// Starter task added to an empty task list after onboarding: shows how completing a task works + /// + /// In en, this message translates to: + /// **'Check this off to finish a task'** + String get onboardingStarterTaskCheck; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index 46583e2b785..608ad604c25 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -4855,7 +4855,7 @@ class AppLocalizationsAr extends AppLocalizations { 'بالمتابعة، سيتم تخزين محادثاتك وتسجيلاتك ومعلوماتك الشخصية بشكل آمن على خوادمنا. تتم معالجة تسجيلاتك الصوتية ونصوصك بواسطة خدمات ذكاء اصطناعي تابعة لجهات خارجية (بما في ذلك Deepgram للنسخ و OpenAI للتحليل) لتزويدك برؤى مدعومة بالذكاء الاصطناعي وتمكين جميع ميزات التطبيق.'; @override - String get tasksEmptyStateMessage => 'ابدأ محادثة لإنشاء مهمة.'; + String get tasksEmptyStateMessage => 'ستظهر هنا المهام من محادثاتك.'; @override String get clearChatAction => 'مسح المحادثة'; @@ -13328,4 +13328,31 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'اضغط للإصلاح'; + + @override + String get consentShortMessage => 'يستخدم Omi الذكاء الاصطناعي لتحويل محادثاتك إلى ملاحظات. يمكنك حذفها في أي وقت.'; + + @override + String consentAiProviders(String providers) { + return 'تتم المعالجة بواسطة $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'يمكنك إعداد بصمة صوتك في أي وقت من الإعدادات.'; + + @override + String get homeNewRecordingButton => 'تسجيل جديد'; + + @override + String get chatWelcomeMessage => + 'مرحبًا، أنا Omi. اسألني عن أي شيء تحدثت عنه: ما قاله شخص ما، أو ما وعدت به، أو ملخص يومك. يمكنني أيضًا إضافة مهام وتذكّر الأشياء لك.'; + + @override + String get onboardingStarterTaskAsk => 'اسأل Omi عن يومك'; + + @override + String get onboardingStarterTaskRecord => 'سجّل محادثة مع Omi'; + + @override + String get onboardingStarterTaskCheck => 'ضع علامة على هذه المهمة لإنهائها'; } diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index 9d681165967..04eca7a7b51 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -4905,7 +4905,7 @@ class AppLocalizationsBe extends AppLocalizations { 'Працягваючы, вашы размовы, запісы і асабістая інфармацыя будуць надзейна захоўвацца на нашых серверах. Вашы аўдыязапісы і транскрыпцыі апрацоўваюцца староннімі сэрвісамі ШІ (уключаючы Deepgram для транскрыпцыі і OpenAI для аналізу), каб забяспечыць вас аналітыкай на аснове ШІ і ўключыць усе функцыі праграмы.'; @override - String get tasksEmptyStateMessage => 'Пачніце размову, каб стварыць задачу.'; + String get tasksEmptyStateMessage => 'Справы з вашых размоў з\'явяцца тут.'; @override String get clearChatAction => 'Очыстіць Чат'; @@ -13436,4 +13436,32 @@ class AppLocalizationsBe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Націсніце, каб выправіць'; + + @override + String get consentShortMessage => + 'Omi з дапамогай ШІ ператварае вашы размовы ў нататкі. Іх можна выдаліць у любы момант.'; + + @override + String consentAiProviders(String providers) { + return 'Апрацоўка: $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Профіль голасу заўсёды можна наладзіць у Наладах.'; + + @override + String get homeNewRecordingButton => 'Новы запіс'; + + @override + String get chatWelcomeMessage => + 'Прывітанне, я Omi. Пытайцеся пра ўсё, пра што вы размаўлялі: што хтосьці сказаў, што вы паабяцалі, ці як прайшоў ваш дзень. Я таксама магу дадаваць задачы і запамінаць рэчы за вас.'; + + @override + String get onboardingStarterTaskAsk => 'Спытайце Omi пра свой дзень'; + + @override + String get onboardingStarterTaskRecord => 'Запішыце размову з Omi'; + + @override + String get onboardingStarterTaskCheck => 'Адзначце гэтую задачу, каб завяршыць яе'; } diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index f34b3346053..a0e83f6fcea 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -4910,7 +4910,7 @@ class AppLocalizationsBg extends AppLocalizations { 'С продължаването вашите разговори, записи и лична информация ще бъдат сигурно съхранени на нашите сървъри. Вашите аудиозаписи и транскрипции се обработват от AI услуги на трети страни (включително Deepgram за транскрипция и OpenAI за анализ), за да ви предоставят прозрения, базирани на AI, и да активират всички функции на приложението.'; @override - String get tasksEmptyStateMessage => 'Започнете разговор, за да създадете задача.'; + String get tasksEmptyStateMessage => 'Задачите от разговорите ви ще се появят тук.'; @override String get clearChatAction => 'Изчисти чата'; @@ -13449,4 +13449,32 @@ class AppLocalizationsBg extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Докоснете, за да поправите'; + + @override + String get consentShortMessage => + 'Omi използва ИИ, за да превърне разговорите ви в бележки. Можете да ги изтриете по всяко време.'; + + @override + String consentAiProviders(String providers) { + return 'Обработва се с $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Можете да настроите гласовия профил по всяко време от Настройки.'; + + @override + String get homeNewRecordingButton => 'Нов запис'; + + @override + String get chatWelcomeMessage => + 'Здравей, аз съм Omi. Питай ме за всичко, за което си говорил: какво е казал някой, какво си обещал или обобщение на деня ти. Мога и да добавям задачи и да запомням неща вместо теб.'; + + @override + String get onboardingStarterTaskAsk => 'Попитай Omi за деня си'; + + @override + String get onboardingStarterTaskRecord => 'Запиши разговор с Omi'; + + @override + String get onboardingStarterTaskCheck => 'Отметни тази задача, за да я завършиш'; } diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index 63b350edf7e..aabb28dd8f3 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -4899,7 +4899,7 @@ class AppLocalizationsBn extends AppLocalizations { 'চালিয়ে যাওয়ার মাধ্যমে, আপনার কথোপকথন, রেকর্ডিং এবং ব্যক্তিগত তথ্য আমাদের সার্ভারে নিরাপদে সংরক্ষণ করা হবে। আপনার অডিও রেকর্ডিং এবং ট্রান্সক্রিপ্ট তৃতীয় পক্ষের AI পরিষেবা দ্বারা প্রক্রিয়া করা হয় (ট্রান্সক্রিপশনের জন্য Deepgram এবং বিশ্লেষণের জন্য OpenAI সহ) যাতে আপনাকে AI-চালিত অন্তর্দৃষ্টি প্রদান করা যায় এবং সমস্ত অ্যাপ বৈশিষ্ট্য সক্ষম করা যায়।'; @override - String get tasksEmptyStateMessage => 'একটি কাজ তৈরি করতে কথোপকথন শুরু করুন।'; + String get tasksEmptyStateMessage => 'আপনার কথোপকথনের করণীয়গুলো এখানে দেখা যাবে।'; @override String get clearChatAction => 'চ্যাট সাফ করুন'; @@ -13398,4 +13398,31 @@ class AppLocalizationsBn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ঠিক করতে ট্যাপ করুন'; + + @override + String get consentShortMessage => 'Omi AI ব্যবহার করে আপনার কথোপকথনকে নোটে পরিণত করে। যেকোনো সময় মুছে ফেলতে পারেন।'; + + @override + String consentAiProviders(String providers) { + return '$providers দিয়ে প্রক্রিয়া করা হয়।'; + } + + @override + String get voiceProfileCardDismissed => 'সেটিংসে যেকোনো সময় ভয়েস প্রোফাইল সেট করতে পারেন।'; + + @override + String get homeNewRecordingButton => 'নতুন রেকর্ডিং'; + + @override + String get chatWelcomeMessage => + 'হাই, আমি Omi। আপনি যা নিয়ে কথা বলেছেন তার যেকোনো কিছু জিজ্ঞেস করুন: কেউ কী বলেছে, আপনি কী প্রতিশ্রুতি দিয়েছেন, বা আপনার দিনের সারাংশ। আমি কাজ যোগ করতে এবং আপনার জন্য জিনিস মনে রাখতেও পারি।'; + + @override + String get onboardingStarterTaskAsk => 'Omi-কে আপনার দিন সম্পর্কে জিজ্ঞেস করুন'; + + @override + String get onboardingStarterTaskRecord => 'Omi দিয়ে একটি কথোপকথন রেকর্ড করুন'; + + @override + String get onboardingStarterTaskCheck => 'শেষ করতে এই কাজটিতে টিক দিন'; } diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index cabeac8734c..c122fd2c654 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -4901,7 +4901,7 @@ class AppLocalizationsBs extends AppLocalizations { 'Nastavljanjem, vaši razgovori, snimke i lični podaci bit će sigurno pohranjeni na našim serverima. Vaši audio zapisi i transkripti se obrađuju od strane AI usluga trećih strana (uključujući Deepgram za transkripciju i OpenAI za analizu) kako bi vam pružili uvide pokretane vještačkom inteligencijom i omogućili sve funkcije aplikacije.'; @override - String get tasksEmptyStateMessage => 'Započnite razgovor da kreirate zadatak.'; + String get tasksEmptyStateMessage => 'Zadaci iz vaših razgovora pojavit će se ovdje.'; @override String get clearChatAction => 'Očisti razgovor'; @@ -13431,4 +13431,32 @@ class AppLocalizationsBs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dodirnite da popravite'; + + @override + String get consentShortMessage => + 'Omi koristi AI da vaše razgovore pretvori u bilješke. Možete ih izbrisati bilo kada.'; + + @override + String consentAiProviders(String providers) { + return 'Obrađuje se pomoću $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Glasovni profil možete podesiti bilo kada u Postavkama.'; + + @override + String get homeNewRecordingButton => 'Novo snimanje'; + + @override + String get chatWelcomeMessage => + 'Zdravo, ja sam Omi. Pitaj me bilo šta o čemu si pričao: šta je neko rekao, šta si obećao ili sažetak tvog dana. Mogu i dodavati zadatke i pamtiti stvari za tebe.'; + + @override + String get onboardingStarterTaskAsk => 'Pitaj Omi o svom danu'; + + @override + String get onboardingStarterTaskRecord => 'Snimi razgovor uz Omi'; + + @override + String get onboardingStarterTaskCheck => 'Označi ovaj zadatak da ga završiš'; } diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index 47579e70465..d3e3bba6a29 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -4922,7 +4922,7 @@ class AppLocalizationsCa extends AppLocalizations { 'Continuant, les vostres converses, enregistraments i informació personal s\'emmagatzemaran de manera segura als nostres servidors. Els vostres enregistraments d\'àudio i transcripcions són processats per serveis d\'IA de tercers (incloent Deepgram per a la transcripció i OpenAI per a l\'anàlisi) per proporcionar-vos informació impulsada per IA i habilitar totes les funcions de l\'aplicació.'; @override - String get tasksEmptyStateMessage => 'Inicia una conversa per crear una tasca.'; + String get tasksEmptyStateMessage => 'Les tasques de les teves converses apareixeran aquí.'; @override String get clearChatAction => 'Esborrar el xat'; @@ -13470,4 +13470,32 @@ class AppLocalizationsCa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toca per solucionar-ho'; + + @override + String get consentShortMessage => + 'Omi fa servir la IA per convertir les teves converses en notes. Les pots esborrar quan vulguis.'; + + @override + String consentAiProviders(String providers) { + return 'Processat amb $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Pots configurar el perfil de veu quan vulguis a Configuració.'; + + @override + String get homeNewRecordingButton => 'Nova gravació'; + + @override + String get chatWelcomeMessage => + 'Hola, soc l\'Omi. Pregunta\'m qualsevol cosa del que has parlat: què va dir algú, què vas prometre o un resum del teu dia. També puc afegir tasques i recordar coses per tu.'; + + @override + String get onboardingStarterTaskAsk => 'Pregunta a l\'Omi pel teu dia'; + + @override + String get onboardingStarterTaskRecord => 'Grava una conversa amb l\'Omi'; + + @override + String get onboardingStarterTaskCheck => 'Marca aquesta tasca per completar-la'; } diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index c7cec7952ce..72e2d855913 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -4885,7 +4885,7 @@ class AppLocalizationsCs extends AppLocalizations { 'Pokračováním budou vaše konverzace, nahrávky a osobní údaje bezpečně uloženy na našich serverech. Vaše audio nahrávky a přepisy jsou zpracovávány AI službami třetích stran (včetně Deepgram pro přepis a OpenAI pro analýzu), aby vám poskytly poznatky založené na AI a umožnily všechny funkce aplikace.'; @override - String get tasksEmptyStateMessage => 'Začněte konverzaci a vytvořte úkol.'; + String get tasksEmptyStateMessage => 'Úkoly z vašich konverzací se zobrazí tady.'; @override String get clearChatAction => 'Vymazat chat'; @@ -13403,4 +13403,31 @@ class AppLocalizationsCs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Klepnutím opravíte'; + + @override + String get consentShortMessage => 'Omi pomocí AI mění vaše konverzace v poznámky. Kdykoli je můžete smazat.'; + + @override + String consentAiProviders(String providers) { + return 'Zpracovává $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Hlasový profil můžete kdykoli nastavit v Nastavení.'; + + @override + String get homeNewRecordingButton => 'Nový záznam'; + + @override + String get chatWelcomeMessage => + 'Ahoj, jsem Omi. Zeptej se mě na cokoli, o čem jsi mluvil: co kdo řekl, co jsi slíbil nebo shrnutí tvého dne. Umím také přidávat úkoly a pamatovat si věci za tebe.'; + + @override + String get onboardingStarterTaskAsk => 'Zeptej se Omi na svůj den'; + + @override + String get onboardingStarterTaskRecord => 'Nahraj rozhovor s Omi'; + + @override + String get onboardingStarterTaskCheck => 'Odškrtni tento úkol a dokonči ho'; } diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index fb6cf3cfa23..efb43e9b34d 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -4880,7 +4880,7 @@ class AppLocalizationsDa extends AppLocalizations { 'Ved at fortsætte vil dine samtaler, optagelser og personlige oplysninger blive sikkert gemt på vores servere. Dine lydoptagelser og udskrifter behandles af tredjeparts AI-tjenester (herunder Deepgram til transskription og OpenAI til analyse) for at give dig AI-drevne indsigter og aktivere alle appfunktioner.'; @override - String get tasksEmptyStateMessage => 'Start en samtale for at oprette en opgave.'; + String get tasksEmptyStateMessage => 'Opgaver fra dine samtaler vises her.'; @override String get clearChatAction => 'Ryd chat'; @@ -13388,4 +13388,32 @@ class AppLocalizationsDa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tryk for at rette'; + + @override + String get consentShortMessage => + 'Omi bruger AI til at gøre dine samtaler til noter. Du kan slette dem når som helst.'; + + @override + String consentAiProviders(String providers) { + return 'Behandles med $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Du kan altid oprette din stemmeprofil under Indstillinger.'; + + @override + String get homeNewRecordingButton => 'Ny optagelse'; + + @override + String get chatWelcomeMessage => + 'Hej, jeg er Omi. Spørg mig om alt, du har talt om: hvad nogen sagde, hvad du lovede, eller et overblik over din dag. Jeg kan også tilføje opgaver og huske ting for dig.'; + + @override + String get onboardingStarterTaskAsk => 'Spørg Omi om din dag'; + + @override + String get onboardingStarterTaskRecord => 'Optag en samtale med Omi'; + + @override + String get onboardingStarterTaskCheck => 'Sæt flueben ved denne opgave for at fuldføre den'; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index a0f67869e43..07d5025b407 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -4931,7 +4931,7 @@ class AppLocalizationsDe extends AppLocalizations { 'Durch Fortfahren werden Ihre Gespräche, Aufnahmen und persönlichen Daten sicher auf unseren Servern gespeichert. Ihre Audioaufnahmen und Transkripte werden von KI-Diensten Dritter verarbeitet (einschließlich Deepgram für die Transkription und OpenAI für die Analyse), um Ihnen KI-gestützte Erkenntnisse zu liefern und alle App-Funktionen zu ermöglichen.'; @override - String get tasksEmptyStateMessage => 'Starte ein Gespräch, um eine Aufgabe zu erstellen.'; + String get tasksEmptyStateMessage => 'To-dos aus deinen Gesprächen erscheinen hier.'; @override String get clearChatAction => 'Chat löschen'; @@ -13500,4 +13500,31 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Zum Beheben tippen'; + + @override + String get consentShortMessage => 'Omi macht mit KI aus deinen Gesprächen Notizen. Du kannst sie jederzeit löschen.'; + + @override + String consentAiProviders(String providers) { + return 'Verarbeitet mit $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Dein Stimmprofil kannst du jederzeit in den Einstellungen einrichten.'; + + @override + String get homeNewRecordingButton => 'Neue Aufnahme'; + + @override + String get chatWelcomeMessage => + 'Hi, ich bin Omi. Frag mich alles, worüber du gesprochen hast: was jemand gesagt hat, was du versprochen hast oder eine Zusammenfassung deines Tages. Ich kann auch Aufgaben hinzufügen und mir Dinge für dich merken.'; + + @override + String get onboardingStarterTaskAsk => 'Frag Omi nach deinem Tag'; + + @override + String get onboardingStarterTaskRecord => 'Nimm ein Gespräch mit Omi auf'; + + @override + String get onboardingStarterTaskCheck => 'Hake diese Aufgabe ab, um sie zu erledigen'; } diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 49ced25403e..a31fb5d5073 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -4932,7 +4932,7 @@ class AppLocalizationsEl extends AppLocalizations { 'Συνεχίζοντας, οι συνομιλίες, οι εγγραφές και τα προσωπικά σας στοιχεία θα αποθηκευτούν με ασφάλεια στους διακομιστές μας. Οι ηχογραφήσεις και τα μεταγραφές σας επεξεργάζονται από υπηρεσίες τεχνητής νοημοσύνης τρίτων (συμπεριλαμβανομένων των Deepgram για μεταγραφή και OpenAI για ανάλυση) για να σας παρέχουν γνώσεις βασισμένες σε AI και να ενεργοποιήσουν όλες τις λειτουργίες της εφαρμογής.'; @override - String get tasksEmptyStateMessage => 'Ξεκινήστε μια συνομιλία για να δημιουργήσετε μια εργασία.'; + String get tasksEmptyStateMessage => 'Οι εργασίες από τις συζητήσεις σας θα εμφανίζονται εδώ.'; @override String get clearChatAction => 'Διαγραφή συνομιλίας'; @@ -13485,4 +13485,32 @@ class AppLocalizationsEl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Πατήστε για διόρθωση'; + + @override + String get consentShortMessage => + 'Το Omi χρησιμοποιεί AI για να μετατρέπει τις συνομιλίες σου σε σημειώσεις. Μπορείς να τις διαγράψεις οποιαδήποτε στιγμή.'; + + @override + String consentAiProviders(String providers) { + return 'Επεξεργασία μέσω $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Μπορείτε να ρυθμίσετε το προφίλ φωνής ανά πάσα στιγμή από τις Ρυθμίσεις.'; + + @override + String get homeNewRecordingButton => 'Νέα ηχογράφηση'; + + @override + String get chatWelcomeMessage => + 'Γεια, είμαι το Omi. Ρώτα με οτιδήποτε έχεις συζητήσει: τι είπε κάποιος, τι υποσχέθηκες ή μια σύνοψη της μέρας σου. Μπορώ επίσης να προσθέτω εργασίες και να θυμάμαι πράγματα για σένα.'; + + @override + String get onboardingStarterTaskAsk => 'Ρώτα το Omi για τη μέρα σου'; + + @override + String get onboardingStarterTaskRecord => 'Ηχογράφησε μια συζήτηση με το Omi'; + + @override + String get onboardingStarterTaskCheck => 'Σημείωσε αυτή την εργασία για να την ολοκληρώσεις'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 5a565692541..8427bc3c6f8 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -4891,7 +4891,7 @@ class AppLocalizationsEn extends AppLocalizations { 'By continuing, your conversations, recordings, and personal information will be securely stored on our servers. Your audio recordings and transcripts are processed by third-party AI services — Deepgram for transcription and OpenAI for analysis — to provide you with AI-powered insights and enable all app features.'; @override - String get tasksEmptyStateMessage => 'Start a conversation to create a task.'; + String get tasksEmptyStateMessage => 'To-dos from your conversations will show up here.'; @override String get clearChatAction => 'Clear Chat'; @@ -13386,4 +13386,31 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tap to fix'; + + @override + String get consentShortMessage => 'Omi uses AI to turn your conversations into notes. You can delete them anytime.'; + + @override + String consentAiProviders(String providers) { + return 'Processed with $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'You can set up Voice Profile anytime in Settings.'; + + @override + String get homeNewRecordingButton => 'New Recording'; + + @override + String get chatWelcomeMessage => + 'Hi, I\'m Omi. Ask me anything you\'ve talked about: what someone said, what you promised, or a summary of your day. I can also add to-dos and remember things for you.'; + + @override + String get onboardingStarterTaskAsk => 'Ask Omi about your day'; + + @override + String get onboardingStarterTaskRecord => 'Record a conversation with Omi'; + + @override + String get onboardingStarterTaskCheck => 'Check this off to finish a task'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index e62f814bbd5..130a0da9d99 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -4897,7 +4897,7 @@ class AppLocalizationsEs extends AppLocalizations { 'Al continuar, tus conversaciones, grabaciones e información personal se almacenarán de forma segura en nuestros servidores. Tus grabaciones de audio y transcripciones son procesadas por servicios de IA de terceros (incluyendo Deepgram para transcripción y OpenAI para análisis) para proporcionarte información impulsada por IA y habilitar todas las funciones de la aplicación.'; @override - String get tasksEmptyStateMessage => 'Inicia una conversación para crear una tarea.'; + String get tasksEmptyStateMessage => 'Las tareas de tus conversaciones aparecerán aquí.'; @override String get clearChatAction => 'Borrar chat'; @@ -13441,4 +13441,32 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toca para solucionarlo'; + + @override + String get consentShortMessage => + 'Omi usa IA para convertir tus conversaciones en notas. Puedes eliminarlas cuando quieras.'; + + @override + String consentAiProviders(String providers) { + return 'Procesado con $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Puedes configurar tu perfil de voz cuando quieras en Ajustes.'; + + @override + String get homeNewRecordingButton => 'Nueva grabación'; + + @override + String get chatWelcomeMessage => + 'Hola, soy Omi. Pregúntame lo que quieras sobre lo que has hablado: lo que dijo alguien, lo que prometiste o un resumen de tu día. También puedo añadir tareas y recordar cosas por ti.'; + + @override + String get onboardingStarterTaskAsk => 'Pregúntale a Omi por tu día'; + + @override + String get onboardingStarterTaskRecord => 'Graba una conversación con Omi'; + + @override + String get onboardingStarterTaskCheck => 'Marca esta tarea para completarla'; } diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 89d7a59602b..3b04534e01d 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -4889,7 +4889,7 @@ class AppLocalizationsEt extends AppLocalizations { 'Jätkates salvestatakse teie vestlused, salvestised ja isikuandmed turvaliselt meie serverites. Teie helisalvestisi ja transkriptsioone töötlevad kolmandate osapoolte AI-teenused (sealhulgas Deepgram transkriptsiooni ja OpenAI analüüsi jaoks), et pakkuda teile AI-põhiseid ülevaateid ja võimaldada kõiki rakenduse funktsioone.'; @override - String get tasksEmptyStateMessage => 'Ülesande loomiseks alusta vestlust.'; + String get tasksEmptyStateMessage => 'Sinu vestlustest pärit ülesanded ilmuvad siia.'; @override String get clearChatAction => 'Tühjenda vestlus'; @@ -13395,4 +13395,32 @@ class AppLocalizationsEt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Puuduta parandamiseks'; + + @override + String get consentShortMessage => + 'Omi muudab tehisintellekti abil sinu vestlused märkmeteks. Saad need igal ajal kustutada.'; + + @override + String consentAiProviders(String providers) { + return 'Töödeldakse teenustega $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Häälprofiili saad seadistada igal ajal seadetes.'; + + @override + String get homeNewRecordingButton => 'Uus salvestus'; + + @override + String get chatWelcomeMessage => + 'Tere, mina olen Omi. Küsi minult kõike, millest oled rääkinud: mida keegi ütles, mida sa lubasid või kokkuvõtet oma päevast. Saan ka ülesandeid lisada ja asju sinu eest meeles pidada.'; + + @override + String get onboardingStarterTaskAsk => 'Küsi Omilt oma päeva kohta'; + + @override + String get onboardingStarterTaskRecord => 'Salvesta Omiga üks vestlus'; + + @override + String get onboardingStarterTaskCheck => 'Märgi see ülesanne tehtuks'; } diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index 6a19c6972db..4957f0e7be0 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -4892,7 +4892,7 @@ class AppLocalizationsFa extends AppLocalizations { 'با ادامه دادن، مکالمات، ضبط‌ها و اطلاعات شخصی شما به طور ایمن در سرورهای ما ذخیره می‌شود. ضبط‌های صوتی و رونوشت‌های شما توسط سرویس‌های هوش مصنوعی شخص ثالث (از جمله Deepgram برای رونویسی و OpenAI برای تحلیل) پردازش می‌شوند تا بینش‌های مبتنی بر هوش مصنوعی را به شما ارائه دهند و تمام ویژگی‌های برنامه را فعال کنند.'; @override - String get tasksEmptyStateMessage => 'برای ایجاد یک کار، گفتگو را شروع کنید.'; + String get tasksEmptyStateMessage => 'کارهای گفتگوهای شما اینجا نمایش داده می‌شوند.'; @override String get clearChatAction => 'پاک‌کردن چت'; @@ -13396,4 +13396,32 @@ class AppLocalizationsFa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'برای رفع ضربه بزنید'; + + @override + String get consentShortMessage => + 'Omi با هوش مصنوعی گفتگوهای شما را به یادداشت تبدیل می‌کند. هر زمان بخواهید می‌توانید آن‌ها را حذف کنید.'; + + @override + String consentAiProviders(String providers) { + return 'پردازش با $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'هر زمان می‌توانید نمایه صدا را در تنظیمات راه‌اندازی کنید.'; + + @override + String get homeNewRecordingButton => 'ضبط جدید'; + + @override + String get chatWelcomeMessage => + 'سلام، من Omi هستم. هر چیزی درباره صحبت‌هایت بپرس: اینکه کسی چه گفت، چه قولی دادی یا خلاصه‌ای از روزت. می‌توانم کار هم اضافه کنم و چیزها را برایت به خاطر بسپارم.'; + + @override + String get onboardingStarterTaskAsk => 'از Omi درباره روزت بپرس'; + + @override + String get onboardingStarterTaskRecord => 'یک گفتگو را با Omi ضبط کن'; + + @override + String get onboardingStarterTaskCheck => 'این کار را تیک بزن تا تمام شود'; } diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 9ab10aee4be..12b149a131d 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -4894,7 +4894,7 @@ class AppLocalizationsFi extends AppLocalizations { 'Jatkamalla keskustelusi, tallenteet ja henkilötietosi tallennetaan turvallisesti palvelimillemme. Äänitallenteitasi ja transkriptioitasi käsittelevät kolmannen osapuolen tekoälypalvelut (mukaan lukien Deepgram transkriptiota ja OpenAI analyysiä varten) tarjotaksemme sinulle tekoälypohjaisia oivalluksia ja mahdollistaaksemme kaikki sovelluksen ominaisuudet.'; @override - String get tasksEmptyStateMessage => 'Aloita keskustelu luodaksesi tehtävän.'; + String get tasksEmptyStateMessage => 'Keskusteluistasi poimitut tehtävät näkyvät täällä.'; @override String get clearChatAction => 'Tyhjennä keskustelu'; @@ -13406,4 +13406,32 @@ class AppLocalizationsFi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Korjaa napauttamalla'; + + @override + String get consentShortMessage => + 'Omi tekee keskusteluistasi muistiinpanoja tekoälyn avulla. Voit poistaa ne milloin tahansa.'; + + @override + String consentAiProviders(String providers) { + return 'Käsittely: $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Voit määrittää ääniprofiilin milloin tahansa asetuksissa.'; + + @override + String get homeNewRecordingButton => 'Uusi tallennus'; + + @override + String get chatWelcomeMessage => + 'Hei, olen Omi. Kysy minulta mitä tahansa siitä, mistä olet puhunut: mitä joku sanoi, mitä lupasit tai yhteenveto päivästäsi. Voin myös lisätä tehtäviä ja muistaa asioita puolestasi.'; + + @override + String get onboardingStarterTaskAsk => 'Kysy Omilta päivästäsi'; + + @override + String get onboardingStarterTaskRecord => 'Tallenna keskustelu Omilla'; + + @override + String get onboardingStarterTaskCheck => 'Merkitse tämä tehtävä tehdyksi'; } diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 9191c9db155..f878f950854 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -4942,7 +4942,7 @@ class AppLocalizationsFr extends AppLocalizations { 'En continuant, vos conversations, enregistrements et informations personnelles seront stockés en toute sécurité sur nos serveurs. Vos enregistrements audio et transcriptions sont traités par des services d\'IA tiers (notamment Deepgram pour la transcription et OpenAI pour l\'analyse) afin de vous fournir des informations alimentées par l\'IA et d\'activer toutes les fonctionnalités de l\'application.'; @override - String get tasksEmptyStateMessage => 'Démarrez une conversation pour créer une tâche.'; + String get tasksEmptyStateMessage => 'Les tâches issues de vos conversations apparaîtront ici.'; @override String get clearChatAction => 'Effacer le chat'; @@ -13507,4 +13507,32 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Touchez pour corriger'; + + @override + String get consentShortMessage => + 'Omi utilise l\'IA pour transformer vos conversations en notes. Vous pouvez les supprimer à tout moment.'; + + @override + String consentAiProviders(String providers) { + return 'Traité avec $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Vous pouvez configurer votre profil vocal à tout moment dans les Réglages.'; + + @override + String get homeNewRecordingButton => 'Nouvel enregistrement'; + + @override + String get chatWelcomeMessage => + 'Salut, je suis Omi. Demandez-moi tout ce dont vous avez parlé : ce que quelqu\'un a dit, ce que vous avez promis ou un résumé de votre journée. Je peux aussi ajouter des tâches et retenir des choses pour vous.'; + + @override + String get onboardingStarterTaskAsk => 'Demandez à Omi comment s\'est passée votre journée'; + + @override + String get onboardingStarterTaskRecord => 'Enregistrez une conversation avec Omi'; + + @override + String get onboardingStarterTaskCheck => 'Cochez cette tâche pour la terminer'; } diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index 9f2d9d6dacd..3cbb3921a80 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -4854,7 +4854,7 @@ class AppLocalizationsHe extends AppLocalizations { 'בהמשך, השיחות, ההקלטות והמידע האישי שלך יאוחסנו בצורה מאובטחת בשרתים שלנו. הקלטות האודיו והתמלולים שלך מעובדים על ידי שירותי AI של צד שלישי (כולל Deepgram לתמלול ו-OpenAI לניתוח) כדי לספק לך תובנות מבוססות AI ולאפשר את כל תכונות האפליקציה.'; @override - String get tasksEmptyStateMessage => 'התחילו שיחה כדי ליצור משימה.'; + String get tasksEmptyStateMessage => 'משימות מהשיחות שלך יופיעו כאן.'; @override String get clearChatAction => 'נקה שיחה'; @@ -13308,4 +13308,31 @@ class AppLocalizationsHe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'הקישו לתיקון'; + + @override + String get consentShortMessage => 'Omi משתמש בבינה מלאכותית כדי להפוך את השיחות שלך להערות. אפשר למחוק אותן בכל עת.'; + + @override + String consentAiProviders(String providers) { + return 'מעובד באמצעות $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'אפשר להגדיר פרופיל קולי בכל עת בהגדרות.'; + + @override + String get homeNewRecordingButton => 'הקלטה חדשה'; + + @override + String get chatWelcomeMessage => + 'היי, אני Omi. שאל אותי כל דבר על מה שדיברת: מה מישהו אמר, מה הבטחת או סיכום של היום שלך. אני יכול גם להוסיף משימות ולזכור דברים בשבילך.'; + + @override + String get onboardingStarterTaskAsk => 'שאל את Omi על היום שלך'; + + @override + String get onboardingStarterTaskRecord => 'הקלט שיחה עם Omi'; + + @override + String get onboardingStarterTaskCheck => 'סמן משימה זו כדי לסיים אותה'; } diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index 30cbcbb02d2..3c455440157 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -4869,7 +4869,7 @@ class AppLocalizationsHi extends AppLocalizations { 'जारी रखने पर, आपकी बातचीत, रिकॉर्डिंग और व्यक्तिगत जानकारी हमारे सर्वर पर सुरक्षित रूप से संग्रहीत की जाएगी। आपकी ऑडियो रिकॉर्डिंग और ट्रांसक्रिप्ट तीसरे पक्ष की AI सेवाओं (ट्रांसक्रिप्शन के लिए Deepgram और विश्लेषण के लिए OpenAI सहित) द्वारा संसाधित किए जाते हैं ताकि आपको AI-संचालित अंतर्दृष्टि प्रदान की जा सके और सभी ऐप सुविधाएँ सक्षम की जा सकें।'; @override - String get tasksEmptyStateMessage => 'टास्क बनाने के लिए बातचीत शुरू करें।'; + String get tasksEmptyStateMessage => 'आपकी बातचीत से निकले काम यहाँ दिखेंगे।'; @override String get clearChatAction => 'चैट साफ़ करें'; @@ -13382,4 +13382,32 @@ class AppLocalizationsHi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ठीक करने के लिए टैप करें'; + + @override + String get consentShortMessage => + 'Omi AI की मदद से आपकी बातचीत को नोट्स में बदलता है। आप उन्हें कभी भी हटा सकते हैं।'; + + @override + String consentAiProviders(String providers) { + return '$providers के साथ प्रोसेस किया जाता है।'; + } + + @override + String get voiceProfileCardDismissed => 'आप सेटिंग्स में कभी भी वॉइस प्रोफ़ाइल सेट कर सकते हैं।'; + + @override + String get homeNewRecordingButton => 'नई रिकॉर्डिंग'; + + @override + String get chatWelcomeMessage => + 'नमस्ते, मैं Omi हूँ। आपने जिस बारे में बात की है, उसके बारे में कुछ भी पूछें: किसी ने क्या कहा, आपने क्या वादा किया, या आपके दिन का सारांश। मैं काम जोड़ सकता हूँ और आपके लिए चीज़ें याद भी रख सकता हूँ।'; + + @override + String get onboardingStarterTaskAsk => 'Omi से अपने दिन के बारे में पूछें'; + + @override + String get onboardingStarterTaskRecord => 'Omi से एक बातचीत रिकॉर्ड करें'; + + @override + String get onboardingStarterTaskCheck => 'इस काम को पूरा करने के लिए टिक करें'; } diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index 74d15d57eca..4b9a0ee2fcd 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -4910,7 +4910,7 @@ class AppLocalizationsHr extends AppLocalizations { 'Nastavljanjem, vaši razgovori, snimke i osobni podaci bit će sigurno pohranjeni na našim poslužiteljima. Vaši audio zapisi i transkripti obrađuju se od strane AI usluga trećih strana (uključujući Deepgram za transkripciju i OpenAI za analizu) kako bi vam pružili uvide pokretane umjetnom inteligencijom i omogućili sve značajke aplikacije.'; @override - String get tasksEmptyStateMessage => 'Započnite razgovor da biste stvorili zadatak.'; + String get tasksEmptyStateMessage => 'Zadaci iz vaših razgovora pojavit će se ovdje.'; @override String get clearChatAction => 'Očisti razgovor'; @@ -13435,4 +13435,32 @@ class AppLocalizationsHr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dodirnite za popravak'; + + @override + String get consentShortMessage => + 'Omi koristi AI kako bi vaše razgovore pretvorio u bilješke. Možete ih izbrisati bilo kada.'; + + @override + String consentAiProviders(String providers) { + return 'Obrađuje se pomoću $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Glasovni profil možete postaviti bilo kada u Postavkama.'; + + @override + String get homeNewRecordingButton => 'Novo snimanje'; + + @override + String get chatWelcomeMessage => + 'Bok, ja sam Omi. Pitaj me bilo što o čemu si razgovarao: što je netko rekao, što si obećao ili sažetak tvog dana. Mogu i dodavati zadatke i pamtiti stvari za tebe.'; + + @override + String get onboardingStarterTaskAsk => 'Pitaj Omi o svom danu'; + + @override + String get onboardingStarterTaskRecord => 'Snimi razgovor uz Omi'; + + @override + String get onboardingStarterTaskCheck => 'Označi ovaj zadatak da ga završiš'; } diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index ace2a62c2fa..79161965ac2 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -4919,7 +4919,7 @@ class AppLocalizationsHu extends AppLocalizations { 'A folytatással beszélgetéseit, felvételeit és személyes adatait biztonságosan tároljuk szervereinken. Hangfelvételeit és átiratait harmadik féltől származó AI szolgáltatások dolgozzák fel (beleértve a Deepgramot az átíráshoz és az OpenAI-t az elemzéshez), hogy AI-alapú betekintéseket nyújtsunk Önnek és az alkalmazás összes funkcióját biztosítsuk.'; @override - String get tasksEmptyStateMessage => 'Feladat létrehozásához kezdj beszélgetést.'; + String get tasksEmptyStateMessage => 'A beszélgetéseidből származó teendők itt jelennek meg.'; @override String get clearChatAction => 'Chat törlése'; @@ -13453,4 +13453,32 @@ class AppLocalizationsHu extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Koppints a javításhoz'; + + @override + String get consentShortMessage => + 'Az Omi mesterséges intelligenciával jegyzetekké alakítja a beszélgetéseidet. Bármikor törölheted őket.'; + + @override + String consentAiProviders(String providers) { + return 'Feldolgozás: $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'A hangprofilt bármikor beállíthatod a Beállításokban.'; + + @override + String get homeNewRecordingButton => 'Új felvétel'; + + @override + String get chatWelcomeMessage => + 'Szia, Omi vagyok. Kérdezz bármit arról, amiről beszéltél: mit mondott valaki, mit ígértél, vagy kérj összefoglalót a napodról. Feladatokat is fel tudok venni, és dolgokat is megjegyzek helyetted.'; + + @override + String get onboardingStarterTaskAsk => 'Kérdezd meg Omit a napodról'; + + @override + String get onboardingStarterTaskRecord => 'Rögzíts egy beszélgetést az Omival'; + + @override + String get onboardingStarterTaskCheck => 'Pipáld ki ezt a feladatot'; } diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 0a98857a36d..1c588a34c03 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -4899,7 +4899,7 @@ class AppLocalizationsId extends AppLocalizations { 'Dengan melanjutkan, percakapan, rekaman, dan informasi pribadi Anda akan disimpan dengan aman di server kami. Rekaman audio dan transkrip Anda diproses oleh layanan AI pihak ketiga (termasuk Deepgram untuk transkripsi dan OpenAI untuk analisis) untuk memberikan Anda wawasan berbasis AI dan mengaktifkan semua fitur aplikasi.'; @override - String get tasksEmptyStateMessage => 'Mulai percakapan untuk membuat tugas.'; + String get tasksEmptyStateMessage => 'Tugas dari percakapan Anda akan muncul di sini.'; @override String get clearChatAction => 'Hapus obrolan'; @@ -13411,4 +13411,32 @@ class AppLocalizationsId extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ketuk untuk memperbaiki'; + + @override + String get consentShortMessage => + 'Omi menggunakan AI untuk mengubah percakapanmu menjadi catatan. Kamu bisa menghapusnya kapan saja.'; + + @override + String consentAiProviders(String providers) { + return 'Diproses dengan $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Anda dapat mengatur Profil Suara kapan saja di Pengaturan.'; + + @override + String get homeNewRecordingButton => 'Rekaman Baru'; + + @override + String get chatWelcomeMessage => + 'Hai, aku Omi. Tanyakan apa saja tentang hal yang kamu bicarakan: apa yang dikatakan seseorang, apa yang kamu janjikan, atau ringkasan harimu. Aku juga bisa menambahkan tugas dan mengingat hal-hal untukmu.'; + + @override + String get onboardingStarterTaskAsk => 'Tanyakan harimu kepada Omi'; + + @override + String get onboardingStarterTaskRecord => 'Rekam percakapan dengan Omi'; + + @override + String get onboardingStarterTaskCheck => 'Centang tugas ini untuk menyelesaikannya'; } diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index db7722db0eb..af87c06a54b 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -4922,7 +4922,7 @@ class AppLocalizationsIt extends AppLocalizations { 'Continuando, le tue conversazioni, registrazioni e informazioni personali saranno archiviate in modo sicuro sui nostri server. Le tue registrazioni audio e trascrizioni vengono elaborate da servizi AI di terze parti (inclusi Deepgram per la trascrizione e OpenAI per l\'analisi) per fornirti approfondimenti basati sull\'AI e abilitare tutte le funzionalità dell\'app.'; @override - String get tasksEmptyStateMessage => 'Avvia una conversazione per creare un\'attività.'; + String get tasksEmptyStateMessage => 'Le attività dalle tue conversazioni appariranno qui.'; @override String get clearChatAction => 'Cancella chat'; @@ -13472,4 +13472,32 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tocca per risolvere'; + + @override + String get consentShortMessage => + 'Omi usa l\'IA per trasformare le tue conversazioni in note. Puoi eliminarle quando vuoi.'; + + @override + String consentAiProviders(String providers) { + return 'Elaborato con $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Puoi configurare il profilo vocale in qualsiasi momento nelle Impostazioni.'; + + @override + String get homeNewRecordingButton => 'Nuova registrazione'; + + @override + String get chatWelcomeMessage => + 'Ciao, sono Omi. Chiedimi qualsiasi cosa di cui hai parlato: cosa ha detto qualcuno, cosa hai promesso o un riepilogo della tua giornata. Posso anche aggiungere attività e ricordare cose per te.'; + + @override + String get onboardingStarterTaskAsk => 'Chiedi a Omi della tua giornata'; + + @override + String get onboardingStarterTaskRecord => 'Registra una conversazione con Omi'; + + @override + String get onboardingStarterTaskCheck => 'Spunta questa attività per completarla'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index ae0ca84985f..cef0442005c 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -4813,7 +4813,7 @@ class AppLocalizationsJa extends AppLocalizations { '続行することで、会話、録音、個人情報は安全に当社のサーバーに保存されます。音声録音と文字起こしは、サードパーティのAIサービス(文字起こし用のDeepgramと分析用のOpenAIを含む)によって処理され、AI駆動のインサイトを提供し、すべてのアプリ機能を有効にします。'; @override - String get tasksEmptyStateMessage => '会話を始めてタスクを作成しましょう。'; + String get tasksEmptyStateMessage => '会話から見つかったToDoがここに表示されます。'; @override String get clearChatAction => 'チャットを消去'; @@ -13213,4 +13213,30 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'タップして修正'; + + @override + String get consentShortMessage => 'OmiはAIで会話をメモにまとめます。メモはいつでも削除できます。'; + + @override + String consentAiProviders(String providers) { + return '$providers で処理されます。'; + } + + @override + String get voiceProfileCardDismissed => 'ボイスプロフィールは設定からいつでも設定できます。'; + + @override + String get homeNewRecordingButton => '新規録音'; + + @override + String get chatWelcomeMessage => 'こんにちは、Omiです。話した内容なら何でも聞いてください。誰が何を言ったか、何を約束したか、一日のまとめなど。タスクの追加や、覚えておくこともできます。'; + + @override + String get onboardingStarterTaskAsk => 'Omiに今日のことを聞いてみる'; + + @override + String get onboardingStarterTaskRecord => 'Omiで会話を録音する'; + + @override + String get onboardingStarterTaskCheck => 'このタスクにチェックして完了する'; } diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index b90d3f25464..70f27acd81a 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -4913,7 +4913,7 @@ class AppLocalizationsKn extends AppLocalizations { 'ಮುಂದುವರಿಯುವ ಮೂಲಕ, ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳು, ರೆಕಾರ್ಡಿಂಗ್‌ಗಳು ಮತ್ತು ವೈಯಕ್ತಿಕ ಮಾಹಿತಿಯನ್ನು ನಮ್ಮ ಸರ್ವರ್‌ಗಳಲ್ಲಿ ಸುರಕ್ಷಿತವಾಗಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ. ನಿಮ್ಮ ಆಡಿಯೋ ರೆಕಾರ್ಡಿಂಗ್‌ಗಳು ಮತ್ತು ಟ್ರಾನ್ಸ್‌ಕ್ರಿಪ್ಟ್‌ಗಳನ್ನು ಮೂರನೇ ವ್ಯಕ್ತಿಯ AI ಸೇವೆಗಳಿಂದ ಸಂಸ್ಕರಿಸಲಾಗುತ್ತದೆ (ಟ್ರಾನ್ಸ್‌ಕ್ರಿಪ್ಷನ್‌ಗಾಗಿ Deepgram ಮತ್ತು ವಿಶ್ಲೇಷಣೆಗಾಗಿ OpenAI ಸೇರಿದಂತೆ) AI-ಚಾಲಿತ ಒಳನೋಟಗಳನ್ನು ಒದಗಿಸಲು ಮತ್ತು ಎಲ್ಲಾ ಅಪ್ಲಿಕೇಶನ್ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲು.'; @override - String get tasksEmptyStateMessage => 'ಕಾರ್ಯವನ್ನು ರಚಿಸಲು ಸಂಭಾಷಣೆಯನ್ನು ಪ್ರಾರಂಭಿಸಿ.'; + String get tasksEmptyStateMessage => 'ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳ ಕಾರ್ಯಗಳು ಇಲ್ಲಿ ಕಾಣಿಸುತ್ತವೆ.'; @override String get clearChatAction => 'ಚ್ಯಾಟ್ ತೀಕ್ಷ್ಣ ಮಾಡಿ'; @@ -13440,4 +13440,31 @@ class AppLocalizationsKn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ಸರಿಪಡಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ'; + + @override + String get consentShortMessage => 'Omi AI ಬಳಸಿ ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳನ್ನು ಟಿಪ್ಪಣಿಗಳಾಗಿ ಮಾಡುತ್ತದೆ. ಯಾವಾಗ ಬೇಕಾದರೂ ಅಳಿಸಬಹುದು.'; + + @override + String consentAiProviders(String providers) { + return '$providers ಮೂಲಕ ಸಂಸ್ಕರಿಸಲಾಗುತ್ತದೆ.'; + } + + @override + String get voiceProfileCardDismissed => 'ಸೆಟ್ಟಿಂಗ್‌ಗಳಲ್ಲಿ ಯಾವಾಗ ಬೇಕಾದರೂ ಧ್ವನಿ ಪ್ರೊಫೈಲ್ ಹೊಂದಿಸಬಹುದು.'; + + @override + String get homeNewRecordingButton => 'ಹೊಸ ರೆಕಾರ್ಡಿಂಗ್'; + + @override + String get chatWelcomeMessage => + 'ನಮಸ್ಕಾರ, ನಾನು Omi. ನೀವು ಮಾತನಾಡಿದ ಯಾವುದರ ಬಗ್ಗೆಯೂ ಕೇಳಿ: ಯಾರು ಏನು ಹೇಳಿದರು, ನೀವು ಏನು ಭರವಸೆ ನೀಡಿದ್ದೀರಿ ಅಥವಾ ನಿಮ್ಮ ದಿನದ ಸಾರಾಂಶ. ನಾನು ಕಾರ್ಯಗಳನ್ನು ಸೇರಿಸಬಹುದು ಮತ್ತು ನಿಮಗಾಗಿ ವಿಷಯಗಳನ್ನು ನೆನಪಿಟ್ಟುಕೊಳ್ಳಬಹುದು.'; + + @override + String get onboardingStarterTaskAsk => 'ನಿಮ್ಮ ದಿನದ ಬಗ್ಗೆ Omi ಅನ್ನು ಕೇಳಿ'; + + @override + String get onboardingStarterTaskRecord => 'Omi ಮೂಲಕ ಒಂದು ಸಂಭಾಷಣೆಯನ್ನು ರೆಕಾರ್ಡ್ ಮಾಡಿ'; + + @override + String get onboardingStarterTaskCheck => 'ಮುಗಿಸಲು ಈ ಕಾರ್ಯವನ್ನು ಟಿಕ್ ಮಾಡಿ'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 04e7a98ceb3..ee054b9ac28 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -4814,7 +4814,7 @@ class AppLocalizationsKo extends AppLocalizations { '계속하면 대화, 녹음 및 개인 정보가 서버에 안전하게 저장됩니다. 오디오 녹음 및 텍스트 변환은 제3자 AI 서비스(전사를 위한 Deepgram 및 분석을 위한 OpenAI 포함)에 의해 처리되어 AI 기반 인사이트를 제공하고 모든 앱 기능을 활성화합니다.'; @override - String get tasksEmptyStateMessage => '대화를 시작하여 할 일을 만드세요.'; + String get tasksEmptyStateMessage => '대화에서 나온 할 일이 여기에 표시돼요.'; @override String get clearChatAction => '채팅 삭제'; @@ -13219,4 +13219,31 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingPermissionTapToFix => '탭하여 해결'; + + @override + String get consentShortMessage => 'Omi는 AI로 대화를 메모로 정리합니다. 언제든지 삭제할 수 있습니다.'; + + @override + String consentAiProviders(String providers) { + return '$providers(으)로 처리됩니다.'; + } + + @override + String get voiceProfileCardDismissed => '음성 프로필은 설정에서 언제든지 설정할 수 있어요.'; + + @override + String get homeNewRecordingButton => '새 녹음'; + + @override + String get chatWelcomeMessage => + '안녕하세요, Omi예요. 나눈 대화라면 무엇이든 물어보세요. 누가 무슨 말을 했는지, 무엇을 약속했는지, 하루 요약까지요. 할 일을 추가하고 기억해 둘 수도 있어요.'; + + @override + String get onboardingStarterTaskAsk => 'Omi에게 오늘 하루에 대해 물어보기'; + + @override + String get onboardingStarterTaskRecord => 'Omi로 대화 녹음하기'; + + @override + String get onboardingStarterTaskCheck => '이 할 일을 체크해서 완료하기'; } diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 33eefea4f69..767910149cd 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -4893,7 +4893,7 @@ class AppLocalizationsLt extends AppLocalizations { 'Tęsdami, jūsų pokalbiai, įrašai ir asmeninė informacija bus saugiai saugomi mūsų serveriuose. Jūsų garso įrašai ir transkripcijos apdorojami trečiųjų šalių AI paslaugų (įskaitant Deepgram transkripcijai ir OpenAI analizei), kad suteiktų jums AI paremtas įžvalgas ir įgalintų visas programėlės funkcijas.'; @override - String get tasksEmptyStateMessage => 'Pradėkite pokalbį, kad sukurtumėte užduotį.'; + String get tasksEmptyStateMessage => 'Užduotys iš jūsų pokalbių bus rodomos čia.'; @override String get clearChatAction => 'Išvalyti pokalbį'; @@ -13425,4 +13425,32 @@ class AppLocalizationsLt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Bakstelėkite, kad pataisytumėte'; + + @override + String get consentShortMessage => + 'Omi naudoja DI, kad jūsų pokalbius paverstų užrašais. Juos galite ištrinti bet kada.'; + + @override + String consentAiProviders(String providers) { + return 'Apdorojama su $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Balso profilį galite nustatyti bet kada nustatymuose.'; + + @override + String get homeNewRecordingButton => 'Naujas įrašas'; + + @override + String get chatWelcomeMessage => + 'Labas, aš Omi. Klausk manęs apie bet ką, apie ką kalbėjai: ką kas nors pasakė, ką pažadėjai ar tavo dienos santrauką. Taip pat galiu pridėti užduočių ir prisiminti dalykus už tave.'; + + @override + String get onboardingStarterTaskAsk => 'Paklausk Omi apie savo dieną'; + + @override + String get onboardingStarterTaskRecord => 'Įrašyk pokalbį su Omi'; + + @override + String get onboardingStarterTaskCheck => 'Pažymėk šią užduotį kaip atliktą'; } diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 58a6b480312..dc0544f598b 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -4900,7 +4900,7 @@ class AppLocalizationsLv extends AppLocalizations { 'Turpinot, jūsu sarunas, ieraksti un personiskā informācija tiks droši glabāta mūsu serveros. Jūsu audio ieraksti un transkripcijas tiek apstrādātas ar trešo pušu AI pakalpojumiem (ieskaitot Deepgram transkripcijai un OpenAI analīzei), lai sniegtu jums AI vadītus ieskatus un iespējotu visas lietotnes funkcijas.'; @override - String get tasksEmptyStateMessage => 'Sāciet sarunu, lai izveidotu uzdevumu.'; + String get tasksEmptyStateMessage => 'Uzdevumi no jūsu sarunām parādīsies šeit.'; @override String get clearChatAction => 'Notīrīt tērzēšanu'; @@ -13430,4 +13430,31 @@ class AppLocalizationsLv extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Pieskarieties, lai labotu'; + + @override + String get consentShortMessage => 'Omi ar MI palīdzību pārvērš jūsu sarunas piezīmēs. Tās var izdzēst jebkurā laikā.'; + + @override + String consentAiProviders(String providers) { + return 'Apstrādā $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Balss profilu varat iestatīt jebkurā laikā iestatījumos.'; + + @override + String get homeNewRecordingButton => 'Jauns ieraksts'; + + @override + String get chatWelcomeMessage => + 'Sveiki, es esmu Omi. Jautā man jebko par to, par ko esi runājis: ko kāds teica, ko tu apsolīji vai kopsavilkumu par tavu dienu. Es varu arī pievienot uzdevumus un atcerēties lietas tavā vietā.'; + + @override + String get onboardingStarterTaskAsk => 'Pajautā Omi par savu dienu'; + + @override + String get onboardingStarterTaskRecord => 'Ieraksti sarunu ar Omi'; + + @override + String get onboardingStarterTaskCheck => 'Atzīmē šo uzdevumu, lai to pabeigtu'; } diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index 95228ffe257..8b034536ecc 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -4916,7 +4916,7 @@ class AppLocalizationsMk extends AppLocalizations { 'Со продолжување, вашите разговори, снимки и лични информации ќе бидат безбедно зачувани на нашите сервери. Вашите аудио снимки и транскрипти се обработуваат од AI услуги на трети страни (вклучувајќи Deepgram за транскрипција и OpenAI за анализа) за да ви обезбедат увиди базирани на AI и да ги овозможат сите функции на апликацијата.'; @override - String get tasksEmptyStateMessage => 'Започнете разговор за да создадете задача.'; + String get tasksEmptyStateMessage => 'Задачите од вашите разговори ќе се појават тука.'; @override String get clearChatAction => 'Очисти разговор'; @@ -13462,4 +13462,32 @@ class AppLocalizationsMk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Допрете за да поправите'; + + @override + String get consentShortMessage => + 'Omi користи вештачка интелигенција за да ги претвори вашите разговори во белешки. Можете да ги избришете во секое време.'; + + @override + String consentAiProviders(String providers) { + return 'Се обработува со $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Гласовниот профил можете да го поставите во секое време во Поставки.'; + + @override + String get homeNewRecordingButton => 'Ново снимање'; + + @override + String get chatWelcomeMessage => + 'Здраво, јас сум Omi. Прашај ме било што за она за што си зборувал: што рекол некој, што си ветил или резиме на твојот ден. Можам и да додавам задачи и да паметам работи за тебе.'; + + @override + String get onboardingStarterTaskAsk => 'Прашај го Omi за твојот ден'; + + @override + String get onboardingStarterTaskRecord => 'Сними разговор со Omi'; + + @override + String get onboardingStarterTaskCheck => 'Штиклирај ја оваа задача за да ја завршиш'; } diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index 77b4c29c5f3..b7bec9c8d4a 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -4903,7 +4903,7 @@ class AppLocalizationsMr extends AppLocalizations { 'पुढे चालू ठेवल्यास, तुमच्या संभाषणा, रेकॉर्डिंग आणि वैयक्तिक माहिती आमच्या सर्व्हरवर सुरक्षितपणे साठवली जाईल. तुमच्या ऑडिओ रेकॉर्डिंग आणि ट्रान्सक्रिप्ट तृतीय-पक्ष AI सेवांद्वारे प्रक्रिया केली जातात (ट्रान्सक्रिप्शनसाठी Deepgram आणि विश्लेषणासाठी OpenAI सह) तुम्हाला AI-चालित अंतर्दृष्टी प्रदान करण्यासाठी आणि सर्व अॅप वैशिष्ट्ये सक्षम करण्यासाठी.'; @override - String get tasksEmptyStateMessage => 'कार्य तयार करण्यासाठी संभाषण सुरू करा.'; + String get tasksEmptyStateMessage => 'तुमच्या संभाषणांमधील कामे इथे दिसतील.'; @override String get clearChatAction => 'चॅट साफ करा'; @@ -13407,4 +13407,31 @@ class AppLocalizationsMr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'दुरुस्त करण्यासाठी टॅप करा'; + + @override + String get consentShortMessage => 'Omi AI वापरून तुमच्या संभाषणांच्या नोट्स बनवते. तुम्ही त्या कधीही हटवू शकता.'; + + @override + String consentAiProviders(String providers) { + return '$providers द्वारे प्रक्रिया केली जाते.'; + } + + @override + String get voiceProfileCardDismissed => 'सेटिंग्जमध्ये कधीही व्हॉइस प्रोफाइल सेट करू शकता.'; + + @override + String get homeNewRecordingButton => 'नवीन रेकॉर्डिंग'; + + @override + String get chatWelcomeMessage => + 'नमस्कार, मी Omi आहे. तुम्ही ज्याबद्दल बोललात त्याबद्दल काहीही विचारा: कोणी काय सांगितले, तुम्ही काय वचन दिले किंवा तुमच्या दिवसाचा सारांश. मी कामे जोडू शकतो आणि तुमच्यासाठी गोष्टी लक्षातही ठेवू शकतो.'; + + @override + String get onboardingStarterTaskAsk => 'Omi ला तुमच्या दिवसाबद्दल विचारा'; + + @override + String get onboardingStarterTaskRecord => 'Omi ने एक संभाषण रेकॉर्ड करा'; + + @override + String get onboardingStarterTaskCheck => 'पूर्ण करण्यासाठी हे काम टिक करा'; } diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index e41432c894a..e889b8a3275 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -4908,7 +4908,7 @@ class AppLocalizationsMs extends AppLocalizations { 'Dengan meneruskan, perbualan, rakaman dan maklumat peribadi anda akan disimpan dengan selamat di pelayan kami. Rakaman audio dan transkrip anda diproses oleh perkhidmatan AI pihak ketiga (termasuk Deepgram untuk transkripsi dan OpenAI untuk analisis) untuk memberikan anda pandangan dikuasakan AI dan membolehkan semua ciri aplikasi.'; @override - String get tasksEmptyStateMessage => 'Mulakan perbualan untuk mencipta tugasan.'; + String get tasksEmptyStateMessage => 'Tugasan daripada perbualan anda akan dipaparkan di sini.'; @override String get clearChatAction => 'Kosongkan sembang'; @@ -13432,4 +13432,32 @@ class AppLocalizationsMs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ketik untuk membetulkan'; + + @override + String get consentShortMessage => + 'Omi menggunakan AI untuk menukar perbualan anda kepada nota. Anda boleh memadamkannya bila-bila masa.'; + + @override + String consentAiProviders(String providers) { + return 'Diproses dengan $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Anda boleh menyediakan Profil Suara pada bila-bila masa dalam Tetapan.'; + + @override + String get homeNewRecordingButton => 'Rakaman Baharu'; + + @override + String get chatWelcomeMessage => + 'Hai, saya Omi. Tanya saya apa sahaja tentang perkara yang anda bualkan: apa yang dikatakan seseorang, apa yang anda janjikan atau ringkasan hari anda. Saya juga boleh menambah tugasan dan mengingati sesuatu untuk anda.'; + + @override + String get onboardingStarterTaskAsk => 'Tanya Omi tentang hari anda'; + + @override + String get onboardingStarterTaskRecord => 'Rakam perbualan dengan Omi'; + + @override + String get onboardingStarterTaskCheck => 'Tandakan tugasan ini untuk menyelesaikannya'; } diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index c2b51687d28..67a006fd20c 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -4904,7 +4904,7 @@ class AppLocalizationsNl extends AppLocalizations { 'Door verder te gaan worden uw gesprekken, opnames en persoonlijke informatie veilig opgeslagen op onze servers. Uw audio-opnames en transcripties worden verwerkt door AI-diensten van derden (waaronder Deepgram voor transcriptie en OpenAI voor analyse) om u AI-gestuurde inzichten te bieden en alle app-functies mogelijk te maken.'; @override - String get tasksEmptyStateMessage => 'Begin een gesprek om een taak aan te maken.'; + String get tasksEmptyStateMessage => 'Taken uit je gesprekken verschijnen hier.'; @override String get clearChatAction => 'Chat wissen'; @@ -13437,4 +13437,31 @@ class AppLocalizationsNl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tik om te herstellen'; + + @override + String get consentShortMessage => 'Omi maakt met AI notities van je gesprekken. Je kunt ze altijd verwijderen.'; + + @override + String consentAiProviders(String providers) { + return 'Verwerkt met $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Je kunt je stemprofiel altijd instellen via Instellingen.'; + + @override + String get homeNewRecordingButton => 'Nieuwe opname'; + + @override + String get chatWelcomeMessage => + 'Hoi, ik ben Omi. Vraag me alles over waar je het over hebt gehad: wat iemand zei, wat je beloofde of een samenvatting van je dag. Ik kan ook taken toevoegen en dingen voor je onthouden.'; + + @override + String get onboardingStarterTaskAsk => 'Vraag Omi naar je dag'; + + @override + String get onboardingStarterTaskRecord => 'Neem een gesprek op met Omi'; + + @override + String get onboardingStarterTaskCheck => 'Vink deze taak af om hem af te ronden'; } diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index e7d6d1231fb..e36ce8de71c 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -4894,7 +4894,7 @@ class AppLocalizationsNo extends AppLocalizations { 'Ved å fortsette vil samtalene, opptakene og den personlige informasjonen din bli lagret sikkert på våre servere. Lydopptakene og transkripsjonene dine behandles av tredjeparts AI-tjenester (inkludert Deepgram for transkripsjon og OpenAI for analyse) for å gi deg AI-drevne innsikter og aktivere alle appfunksjoner.'; @override - String get tasksEmptyStateMessage => 'Start en samtale for å opprette en oppgave.'; + String get tasksEmptyStateMessage => 'Gjøremål fra samtalene dine vises her.'; @override String get clearChatAction => 'Tøm chat'; @@ -13403,4 +13403,32 @@ class AppLocalizationsNo extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Trykk for å fikse'; + + @override + String get consentShortMessage => + 'Omi bruker KI til å gjøre samtalene dine om til notater. Du kan slette dem når som helst.'; + + @override + String consentAiProviders(String providers) { + return 'Behandles med $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Du kan sette opp stemmeprofilen når som helst i Innstillinger.'; + + @override + String get homeNewRecordingButton => 'Ny opptak'; + + @override + String get chatWelcomeMessage => + 'Hei, jeg er Omi. Spør meg om alt du har snakket om: hva noen sa, hva du lovet, eller et sammendrag av dagen din. Jeg kan også legge til oppgaver og huske ting for deg.'; + + @override + String get onboardingStarterTaskAsk => 'Spør Omi om dagen din'; + + @override + String get onboardingStarterTaskRecord => 'Ta opp en samtale med Omi'; + + @override + String get onboardingStarterTaskCheck => 'Kryss av denne oppgaven for å fullføre den'; } diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index 8ca5e7239d1..74e6077cb31 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -4902,7 +4902,7 @@ class AppLocalizationsPl extends AppLocalizations { 'Kontynuując, Twoje rozmowy, nagrania i dane osobowe będą bezpiecznie przechowywane na naszych serwerach. Twoje nagrania audio i transkrypcje są przetwarzane przez zewnętrzne usługi AI (w tym Deepgram do transkrypcji i OpenAI do analizy), aby dostarczyć Ci wglądy oparte na AI i umożliwić wszystkie funkcje aplikacji.'; @override - String get tasksEmptyStateMessage => 'Rozpocznij rozmowę, aby utworzyć zadanie.'; + String get tasksEmptyStateMessage => 'Zadania z Twoich rozmów pojawią się tutaj.'; @override String get clearChatAction => 'Wyczyść czat'; @@ -13440,4 +13440,32 @@ class AppLocalizationsPl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dotknij, aby naprawić'; + + @override + String get consentShortMessage => + 'Omi za pomocą AI zamienia Twoje rozmowy w notatki. Możesz je usunąć w każdej chwili.'; + + @override + String consentAiProviders(String providers) { + return 'Przetwarzane przez $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Profil głosowy możesz skonfigurować w każdej chwili w Ustawieniach.'; + + @override + String get homeNewRecordingButton => 'Nowe nagranie'; + + @override + String get chatWelcomeMessage => + 'Cześć, jestem Omi. Pytaj mnie o wszystko, o czym rozmawiałeś: co ktoś powiedział, co obiecałeś albo jak minął ci dzień. Mogę też dodawać zadania i zapamiętywać rzeczy za ciebie.'; + + @override + String get onboardingStarterTaskAsk => 'Zapytaj Omi o swój dzień'; + + @override + String get onboardingStarterTaskRecord => 'Nagraj rozmowę z Omi'; + + @override + String get onboardingStarterTaskCheck => 'Odhacz to zadanie, aby je ukończyć'; } diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index 4bf1f9c9491..ec2d64825f0 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -4889,7 +4889,7 @@ class AppLocalizationsPt extends AppLocalizations { 'Ao continuar, suas conversas, gravações e informações pessoais serão armazenadas com segurança em nossos servidores. Suas gravações de áudio e transcrições são processadas por serviços de IA de terceiros (incluindo Deepgram para transcrição e OpenAI para análise) para fornecer insights baseados em IA e habilitar todos os recursos do aplicativo.'; @override - String get tasksEmptyStateMessage => 'Inicie uma conversa para criar uma tarefa.'; + String get tasksEmptyStateMessage => 'As tarefas das suas conversas aparecerão aqui.'; @override String get clearChatAction => 'Limpar conversa'; @@ -13425,4 +13425,32 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toque para corrigir'; + + @override + String get consentShortMessage => + 'O Omi usa IA para transformar suas conversas em notas. Você pode excluí-las quando quiser.'; + + @override + String consentAiProviders(String providers) { + return 'Processado com $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Você pode configurar o perfil de voz a qualquer momento em Ajustes.'; + + @override + String get homeNewRecordingButton => 'Nova gravação'; + + @override + String get chatWelcomeMessage => + 'Oi, eu sou o Omi. Pergunte qualquer coisa sobre o que você conversou: o que alguém disse, o que você prometeu ou um resumo do seu dia. Também posso adicionar tarefas e lembrar coisas para você.'; + + @override + String get onboardingStarterTaskAsk => 'Pergunte ao Omi sobre o seu dia'; + + @override + String get onboardingStarterTaskRecord => 'Grave uma conversa com o Omi'; + + @override + String get onboardingStarterTaskCheck => 'Marque esta tarefa para concluí-la'; } diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index 6fbdfca9ae5..a98a1efc57a 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -4917,7 +4917,7 @@ class AppLocalizationsRo extends AppLocalizations { 'Continuând, conversațiile, înregistrările și informațiile dvs. personale vor fi stocate în siguranță pe serverele noastre. Înregistrările audio și transcrierile dvs. sunt procesate de servicii AI terțe (inclusiv Deepgram pentru transcriere și OpenAI pentru analiză) pentru a vă oferi informații bazate pe AI și a activa toate funcțiile aplicației.'; @override - String get tasksEmptyStateMessage => 'Începe o conversație pentru a crea o sarcină.'; + String get tasksEmptyStateMessage => 'Sarcinile din conversațiile tale vor apărea aici.'; @override String get clearChatAction => 'Șterge conversația'; @@ -13456,4 +13456,32 @@ class AppLocalizationsRo extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Atinge pentru a remedia'; + + @override + String get consentShortMessage => + 'Omi folosește AI pentru a transforma conversațiile tale în notițe. Le poți șterge oricând.'; + + @override + String consentAiProviders(String providers) { + return 'Procesat cu $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Poți configura profilul vocal oricând din Setări.'; + + @override + String get homeNewRecordingButton => 'Înregistrare nouă'; + + @override + String get chatWelcomeMessage => + 'Salut, sunt Omi. Întreabă-mă orice despre ce ai vorbit: ce a spus cineva, ce ai promis sau un rezumat al zilei tale. Pot și să adaug sarcini și să țin minte lucruri pentru tine.'; + + @override + String get onboardingStarterTaskAsk => 'Întreabă-l pe Omi despre ziua ta'; + + @override + String get onboardingStarterTaskRecord => 'Înregistrează o conversație cu Omi'; + + @override + String get onboardingStarterTaskCheck => 'Bifează această sarcină ca s-o finalizezi'; } diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index 5ec0cd68802..a35854a31dc 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -4905,7 +4905,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Продолжая, ваши разговоры, записи и личная информация будут надежно храниться на наших серверах. Ваши аудиозаписи и транскрипции обрабатываются сторонними AI-сервисами (включая Deepgram для транскрипции и OpenAI для анализа), чтобы предоставить вам аналитику на основе ИИ и обеспечить работу всех функций приложения.'; @override - String get tasksEmptyStateMessage => 'Начните разговор, чтобы создать задачу.'; + String get tasksEmptyStateMessage => 'Задачи из ваших разговоров появятся здесь.'; @override String get clearChatAction => 'Очистить чат'; @@ -13447,4 +13447,32 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Нажмите, чтобы исправить'; + + @override + String get consentShortMessage => + 'Omi с помощью ИИ превращает ваши разговоры в заметки. Их можно удалить в любой момент.'; + + @override + String consentAiProviders(String providers) { + return 'Обработка: $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Голосовой профиль можно настроить в любое время в Настройках.'; + + @override + String get homeNewRecordingButton => 'Новая запись'; + + @override + String get chatWelcomeMessage => + 'Привет, я Omi. Спрашивайте обо всём, о чём вы говорили: что сказал кто-то, что вы пообещали или как прошёл ваш день. Я также могу добавлять задачи и запоминать важное за вас.'; + + @override + String get onboardingStarterTaskAsk => 'Спросите Omi о своём дне'; + + @override + String get onboardingStarterTaskRecord => 'Запишите разговор с Omi'; + + @override + String get onboardingStarterTaskCheck => 'Отметьте эту задачу, чтобы завершить её'; } diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index a0f945b06e4..600ddad2bd0 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -4889,7 +4889,7 @@ class AppLocalizationsSk extends AppLocalizations { 'Pokračovaním budú vaše konverzácie, nahrávky a osobné údaje bezpečne uložené na našich serveroch. Vaše audio nahrávky a prepisy sú spracovávané AI službami tretích strán (vrátane Deepgram na prepis a OpenAI na analýzu), aby vám poskytli poznatky založené na AI a umožnili všetky funkcie aplikácie.'; @override - String get tasksEmptyStateMessage => 'Začnite konverzáciu a vytvorte úlohu.'; + String get tasksEmptyStateMessage => 'Úlohy z vašich rozhovorov sa zobrazia tu.'; @override String get clearChatAction => 'Vymazať chat'; @@ -13397,4 +13397,31 @@ class AppLocalizationsSk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ťuknutím opravíte'; + + @override + String get consentShortMessage => 'Omi pomocou AI mení vaše konverzácie na poznámky. Kedykoľvek ich môžete vymazať.'; + + @override + String consentAiProviders(String providers) { + return 'Spracúva $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Hlasový profil si môžete kedykoľvek nastaviť v Nastaveniach.'; + + @override + String get homeNewRecordingButton => 'Nový záznam'; + + @override + String get chatWelcomeMessage => + 'Ahoj, som Omi. Opýtaj sa ma na čokoľvek, o čom si hovoril: čo kto povedal, čo si sľúbil alebo zhrnutie tvojho dňa. Viem aj pridávať úlohy a pamätať si veci za teba.'; + + @override + String get onboardingStarterTaskAsk => 'Opýtaj sa Omi na svoj deň'; + + @override + String get onboardingStarterTaskRecord => 'Nahraj rozhovor s Omi'; + + @override + String get onboardingStarterTaskCheck => 'Odškrtni túto úlohu a dokonči ju'; } diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index cb205dd4699..d4df72a37f5 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -4902,7 +4902,7 @@ class AppLocalizationsSl extends AppLocalizations { 'Z nadaljevanjem bodo vaši pogovori, posnetki in osebni podatki varno shranjeni na naših strežnikih. Vaši zvočni posnetki in prepisi se obdelujejo s storitvami umetne inteligence tretjih oseb (vključno z Deepgram za prepis in OpenAI za analizo), da vam zagotovimo vpoglede, ki jih poganja umetna inteligenca, in omogočimo vse funkcije aplikacije.'; @override - String get tasksEmptyStateMessage => 'Začnite pogovor, da ustvarite nalogo.'; + String get tasksEmptyStateMessage => 'Opravila iz vaših pogovorov se bodo prikazala tukaj.'; @override String get clearChatAction => 'Počistite klepet'; @@ -13432,4 +13432,32 @@ class AppLocalizationsSl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tapnite za popravilo'; + + @override + String get consentShortMessage => + 'Omi z umetno inteligenco spremeni vaše pogovore v zapiske. Kadar koli jih lahko izbrišete.'; + + @override + String consentAiProviders(String providers) { + return 'Obdelano z $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Glasovni profil lahko kadar koli nastavite v Nastavitvah.'; + + @override + String get homeNewRecordingButton => 'Novo snemanje'; + + @override + String get chatWelcomeMessage => + 'Živjo, sem Omi. Vprašaj me karkoli o tem, o čemer si govoril: kaj je kdo rekel, kaj si obljubil ali povzetek tvojega dne. Lahko tudi dodajam opravila in si zapomnim stvari namesto tebe.'; + + @override + String get onboardingStarterTaskAsk => 'Vprašaj Omi o svojem dnevu'; + + @override + String get onboardingStarterTaskRecord => 'Posnemi pogovor z Omi'; + + @override + String get onboardingStarterTaskCheck => 'Odkljukaj to opravilo, da ga končaš'; } diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index efec4a5b542..52e63cf2735 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -4894,7 +4894,7 @@ class AppLocalizationsSr extends AppLocalizations { 'Настављањем, ваши разговори, снимци и лични подаци биће безбедно ускладиштени на нашим серверима. Ваши аудио снимци и транскрипти се обрађују од стране AI сервиса трећих страна (укључујући Deepgram за транскрипцију и OpenAI за анализу) како би вам пружили увиде засноване на вештачкој интелигенцији и омогућили све функције апликације.'; @override - String get tasksEmptyStateMessage => 'Започните разговор да бисте направили задатак.'; + String get tasksEmptyStateMessage => 'Задаци из ваших разговора појавиће се овде.'; @override String get clearChatAction => 'Обриши разговор'; @@ -13410,4 +13410,32 @@ class AppLocalizationsSr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Додирните да поправите'; + + @override + String get consentShortMessage => + 'Omi користи AI да ваше разговоре претвори у белешке. Можете их обрисати у било ком тренутку.'; + + @override + String consentAiProviders(String providers) { + return 'Обрађује се помоћу $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Гласовни профил можете подесити било када у Подешавањима.'; + + @override + String get homeNewRecordingButton => 'Ново снимање'; + + @override + String get chatWelcomeMessage => + 'Zdravo, ja sam Omi. Pitaj me bilo šta o čemu si pričao: šta je neko rekao, šta si obećao ili rezime tvog dana. Mogu i da dodajem zadatke i pamtim stvari za tebe.'; + + @override + String get onboardingStarterTaskAsk => 'Pitaj Omi o svom danu'; + + @override + String get onboardingStarterTaskRecord => 'Snimi razgovor uz Omi'; + + @override + String get onboardingStarterTaskCheck => 'Označi ovaj zadatak da ga završiš'; } diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 52225ad76d5..fca68447a87 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -4895,7 +4895,7 @@ class AppLocalizationsSv extends AppLocalizations { 'Genom att fortsätta kommer dina konversationer, inspelningar och personlig information att lagras säkert på våra servrar. Dina ljudinspelningar och transkriptioner behandlas av AI-tjänster från tredje part (inklusive Deepgram för transkription och OpenAI för analys) för att ge dig AI-drivna insikter och aktivera alla appfunktioner.'; @override - String get tasksEmptyStateMessage => 'Starta ett samtal för att skapa en uppgift.'; + String get tasksEmptyStateMessage => 'Att göra från dina samtal visas här.'; @override String get clearChatAction => 'Rensa chatt'; @@ -13406,4 +13406,32 @@ class AppLocalizationsSv extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tryck för att åtgärda'; + + @override + String get consentShortMessage => + 'Omi använder AI för att göra dina samtal till anteckningar. Du kan radera dem när som helst.'; + + @override + String consentAiProviders(String providers) { + return 'Bearbetas med $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Du kan ställa in din röstprofil när som helst i Inställningar.'; + + @override + String get homeNewRecordingButton => 'Ny inspelning'; + + @override + String get chatWelcomeMessage => + 'Hej, jag är Omi. Fråga mig om allt du har pratat om: vad någon sa, vad du lovade eller en sammanfattning av din dag. Jag kan också lägga till uppgifter och komma ihåg saker åt dig.'; + + @override + String get onboardingStarterTaskAsk => 'Fråga Omi om din dag'; + + @override + String get onboardingStarterTaskRecord => 'Spela in ett samtal med Omi'; + + @override + String get onboardingStarterTaskCheck => 'Bocka av den här uppgiften för att slutföra den'; } diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index 09347a5d81d..79cfd0ccef4 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -4933,7 +4933,7 @@ class AppLocalizationsTa extends AppLocalizations { 'தொடர்வதன் மூலம், உங்கள் உரையாடல்கள், பதிவுகள் மற்றும் தனிப்பட்ட தகவல்கள் எங்கள் சேவையகங்களில் பாதுகாப்பாக சேமிக்கப்படும். உங்கள் ஆடியோ பதிவுகள் மற்றும் படியெடுப்புகள் மூன்றாம் தரப்பு AI சேவைகளால் செயலாக்கப்படுகின்றன (படியெடுப்பிற்கு Deepgram மற்றும் பகுப்பாய்விற்கு OpenAI உட்பட) AI இயக்கும் நுண்ணறிவுகளை உங்களுக்கு வழங்கவும் அனைத்து பயன்பாட்டு அம்சங்களையும் இயக்கவும்.'; @override - String get tasksEmptyStateMessage => 'ஒரு பணியை உருவாக்க உரையாடலைத் தொடங்குங்கள்.'; + String get tasksEmptyStateMessage => 'உங்கள் உரையாடல்களில் உள்ள பணிகள் இங்கே தோன்றும்.'; @override String get clearChatAction => 'உரையாடலைத் தெளிக்கவும்'; @@ -13493,4 +13493,32 @@ class AppLocalizationsTa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'சரிசெய்ய தட்டவும்'; + + @override + String get consentShortMessage => + 'Omi AI மூலம் உங்கள் உரையாடல்களைக் குறிப்புகளாக மாற்றுகிறது. எப்போது வேண்டுமானாலும் நீக்கலாம்.'; + + @override + String consentAiProviders(String providers) { + return '$providers மூலம் செயலாக்கப்படுகிறது.'; + } + + @override + String get voiceProfileCardDismissed => 'அமைப்புகளில் எப்போது வேண்டுமானாலும் குரல் சுயவிவரத்தை அமைக்கலாம்.'; + + @override + String get homeNewRecordingButton => 'புதிய பதிவு'; + + @override + String get chatWelcomeMessage => + 'வணக்கம், நான் Omi. நீங்கள் பேசிய எதைப் பற்றியும் கேளுங்கள்: யார் என்ன சொன்னார்கள், நீங்கள் என்ன வாக்குறுதி அளித்தீர்கள் அல்லது உங்கள் நாளின் சுருக்கம். பணிகளைச் சேர்க்கவும் உங்களுக்காக விஷயங்களை நினைவில் வைக்கவும் என்னால் முடியும்.'; + + @override + String get onboardingStarterTaskAsk => 'உங்கள் நாளைப் பற்றி Omi-யிடம் கேளுங்கள்'; + + @override + String get onboardingStarterTaskRecord => 'Omi மூலம் ஒரு உரையாடலைப் பதிவுசெய்யுங்கள்'; + + @override + String get onboardingStarterTaskCheck => 'முடிக்க இந்தப் பணியைக் குறியிடுங்கள்'; } diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index 7ecc8865a2f..77348f2e17b 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -4921,7 +4921,7 @@ class AppLocalizationsTe extends AppLocalizations { 'కొనసాగించడం ద్వారా, మీ సంభాషణలు, రికార్డింగ్‌లు మరియు వ్యక్తిగత సమాచారం మా సర్వర్‌లలో సురక్షితంగా నిల్వ చేయబడతాయి. మీ ఆడియో రికార్డింగ్‌లు మరియు ట్రాన్‌స్క్రిప్ట్‌లు థర్డ్-పార్టీ AI సేవల ద్వారా ప్రాసెస్ చేయబడతాయి (ట్రాన్‌స్క్రిప్షన్ కోసం Deepgram మరియు విశ్లేషణ కోసం OpenAI సహా) AI-ఆధారిత అంతర్దృష్టులను అందించడానికి మరియు అన్ని యాప్ ఫీచర్‌లను ప్రారంభించడానికి.'; @override - String get tasksEmptyStateMessage => 'పనిని సృష్టించడానికి సంభాషణను ప్రారంభించండి.'; + String get tasksEmptyStateMessage => 'మీ సంభాషణల నుండి పనులు ఇక్కడ కనిపిస్తాయి.'; @override String get clearChatAction => 'చాట్‌ను క్లియర్ చేయండి'; @@ -13453,4 +13453,31 @@ class AppLocalizationsTe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'సరిచేయడానికి నొక్కండి'; + + @override + String get consentShortMessage => 'Omi AI సహాయంతో మీ సంభాషణలను నోట్స్‌గా మారుస్తుంది. వాటిని ఎప్పుడైనా తొలగించవచ్చు.'; + + @override + String consentAiProviders(String providers) { + return '$providers ద్వారా ప్రాసెస్ చేయబడుతుంది.'; + } + + @override + String get voiceProfileCardDismissed => 'సెట్టింగ్‌లలో ఎప్పుడైనా వాయిస్ ప్రొఫైల్ సెటప్ చేయవచ్చు.'; + + @override + String get homeNewRecordingButton => 'కొత్త రికార్డింగ్'; + + @override + String get chatWelcomeMessage => + 'హాయ్, నేను Omi. మీరు మాట్లాడిన దేని గురించైనా అడగండి: ఎవరు ఏమన్నారు, మీరు ఏమి మాట ఇచ్చారు లేదా మీ రోజు సారాంశం. నేను పనులను జోడించగలను, మీ కోసం విషయాలు గుర్తుంచుకోగలను.'; + + @override + String get onboardingStarterTaskAsk => 'మీ రోజు గురించి Omi ని అడగండి'; + + @override + String get onboardingStarterTaskRecord => 'Omi తో ఒక సంభాషణను రికార్డ్ చేయండి'; + + @override + String get onboardingStarterTaskCheck => 'పూర్తి చేయడానికి ఈ పనిని టిక్ చేయండి'; } diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 8c625638890..86a128965a5 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -4865,7 +4865,7 @@ class AppLocalizationsTh extends AppLocalizations { 'เมื่อดำเนินการต่อ การสนทนา การบันทึก และข้อมูลส่วนบุคคลของคุณจะถูกจัดเก็บอย่างปลอดภัยบนเซิร์ฟเวอร์ของเรา การบันทึกเสียงและการถอดความของคุณจะถูกประมวลผลโดยบริการ AI ของบุคคลที่สาม (รวมถึง Deepgram สำหรับการถอดความ และ OpenAI สำหรับการวิเคราะห์) เพื่อมอบข้อมูลเชิงลึกที่ขับเคลื่อนด้วย AI และเปิดใช้งานคุณสมบัติทั้งหมดของแอป'; @override - String get tasksEmptyStateMessage => 'เริ่มการสนทนาเพื่อสร้างงาน'; + String get tasksEmptyStateMessage => 'สิ่งที่ต้องทำจากบทสนทนาของคุณจะแสดงที่นี่'; @override String get clearChatAction => 'ล้างแชท'; @@ -13335,4 +13335,31 @@ class AppLocalizationsTh extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'แตะเพื่อแก้ไข'; + + @override + String get consentShortMessage => 'Omi ใช้ AI เปลี่ยนบทสนทนาของคุณเป็นโน้ต คุณลบได้ทุกเมื่อ'; + + @override + String consentAiProviders(String providers) { + return 'ประมวลผลด้วย $providers'; + } + + @override + String get voiceProfileCardDismissed => 'คุณตั้งค่าโปรไฟล์เสียงได้ทุกเมื่อในการตั้งค่า'; + + @override + String get homeNewRecordingButton => 'บันทึกใหม่'; + + @override + String get chatWelcomeMessage => + 'สวัสดี ฉันคือ Omi ถามอะไรก็ได้เกี่ยวกับสิ่งที่คุณคุยไว้ เช่น ใครพูดอะไร คุณสัญญาอะไรไว้ หรือสรุปวันของคุณ ฉันยังเพิ่มสิ่งที่ต้องทำและจำเรื่องต่าง ๆ ให้คุณได้ด้วย'; + + @override + String get onboardingStarterTaskAsk => 'ถาม Omi เกี่ยวกับวันของคุณ'; + + @override + String get onboardingStarterTaskRecord => 'บันทึกบทสนทนาด้วย Omi'; + + @override + String get onboardingStarterTaskCheck => 'ติ๊กงานนี้เพื่อทำให้เสร็จ'; } diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index a9a9839d7e7..d41ddb40992 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -4934,7 +4934,7 @@ class AppLocalizationsTl extends AppLocalizations { 'Sa pagpapatuloy, ang iyong mga pag-uusap, recording, at personal na impormasyon ay ligtas na maiimbak sa aming mga server. Ang iyong mga audio recording at transcript ay pinoproseso ng third-party na mga serbisyo ng AI (kabilang ang Deepgram para sa transcription at OpenAI para sa analysis) upang mabigyan ka ng AI-powered na mga insight at ma-enable ang lahat ng feature ng app.'; @override - String get tasksEmptyStateMessage => 'Magsimula ng pag-uusap para gumawa ng gawain.'; + String get tasksEmptyStateMessage => 'Lalabas dito ang mga gagawin mula sa iyong mga usapan.'; @override String get clearChatAction => 'Burahin ang Chat'; @@ -13506,4 +13506,32 @@ class AppLocalizationsTl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'I-tap para ayusin'; + + @override + String get consentShortMessage => + 'Ginagamit ng Omi ang AI para gawing notes ang iyong mga usapan. Puwede mo itong burahin anumang oras.'; + + @override + String consentAiProviders(String providers) { + return 'Pinoproseso gamit ang $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Maaari mong i-set up ang Voice Profile anumang oras sa Settings.'; + + @override + String get homeNewRecordingButton => 'Bagong Recording'; + + @override + String get chatWelcomeMessage => + 'Hi, ako si Omi. Itanong mo sa akin ang kahit ano tungkol sa mga napag-usapan mo: ang sinabi ng isang tao, ang ipinangako mo, o buod ng araw mo. Kaya ko ring magdagdag ng mga gawain at tandaan ang mga bagay para sa iyo.'; + + @override + String get onboardingStarterTaskAsk => 'Tanungin si Omi tungkol sa araw mo'; + + @override + String get onboardingStarterTaskRecord => 'Mag-record ng usapan gamit ang Omi'; + + @override + String get onboardingStarterTaskCheck => 'Lagyan ng tsek ang gawaing ito para matapos'; } diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 228de7a5a9c..49f325a8df6 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -4901,7 +4901,7 @@ class AppLocalizationsTr extends AppLocalizations { 'Devam ederek, konuşmalarınız, kayıtlarınız ve kişisel bilgileriniz sunucularımızda güvenli bir şekilde saklanacaktır. Ses kayıtlarınız ve transkriptleriniz, size yapay zeka destekli içgörüler sağlamak ve tüm uygulama özelliklerini etkinleştirmek için üçüncü taraf yapay zeka hizmetleri (transkripsiyon için Deepgram ve analiz için OpenAI dahil) tarafından işlenir.'; @override - String get tasksEmptyStateMessage => 'Görev oluşturmak için bir konuşma başlatın.'; + String get tasksEmptyStateMessage => 'Konuşmalarındaki yapılacaklar burada görünecek.'; @override String get clearChatAction => 'Sohbeti temizle'; @@ -13414,4 +13414,32 @@ class AppLocalizationsTr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Düzeltmek için dokunun'; + + @override + String get consentShortMessage => + 'Omi, konuşmalarını yapay zekâ ile notlara dönüştürür. İstediğin zaman silebilirsin.'; + + @override + String consentAiProviders(String providers) { + return '$providers ile işlenir.'; + } + + @override + String get voiceProfileCardDismissed => 'Ses profilini istediğin zaman Ayarlar\'dan kurabilirsin.'; + + @override + String get homeNewRecordingButton => 'Yeni Kayıt'; + + @override + String get chatWelcomeMessage => + 'Merhaba, ben Omi. Konuştuğun her şeyi bana sorabilirsin: birinin ne dediğini, ne söz verdiğini ya da gününün özetini. Görev de ekleyebilir ve senin için bir şeyleri hatırlayabilirim.'; + + @override + String get onboardingStarterTaskAsk => 'Omi\'ye gününü sor'; + + @override + String get onboardingStarterTaskRecord => 'Omi ile bir konuşma kaydet'; + + @override + String get onboardingStarterTaskCheck => 'Bu görevi tamamlamak için işaretle'; } diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index f8d99296702..d39538f0cd9 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -4899,7 +4899,7 @@ class AppLocalizationsUk extends AppLocalizations { 'Продовжуючи, ваші розмови, записи та особиста інформація будуть надійно зберігатися на наших серверах. Ваші аудіозаписи та транскрипції обробляються сторонніми AI-сервісами (включаючи Deepgram для транскрипції та OpenAI для аналізу), щоб надати вам аналітику на основі ШІ та увімкнути всі функції додатку.'; @override - String get tasksEmptyStateMessage => 'Почніть розмову, щоб створити завдання.'; + String get tasksEmptyStateMessage => 'Завдання з ваших розмов з\'являться тут.'; @override String get clearChatAction => 'Очистити чат'; @@ -13431,4 +13431,32 @@ class AppLocalizationsUk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Торкніться, щоб виправити'; + + @override + String get consentShortMessage => + 'Omi за допомогою ШІ перетворює ваші розмови на нотатки. Їх можна видалити будь-коли.'; + + @override + String consentAiProviders(String providers) { + return 'Обробка: $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Голосовий профіль можна налаштувати будь-коли в Налаштуваннях.'; + + @override + String get homeNewRecordingButton => 'Новий запис'; + + @override + String get chatWelcomeMessage => + 'Привіт, я Omi. Питайте про все, про що ви говорили: що хтось сказав, що ви пообіцяли або як минув ваш день. Я також можу додавати завдання й запам\'ятовувати важливе за вас.'; + + @override + String get onboardingStarterTaskAsk => 'Запитайте Omi про свій день'; + + @override + String get onboardingStarterTaskRecord => 'Запишіть розмову з Omi'; + + @override + String get onboardingStarterTaskCheck => 'Позначте це завдання, щоб завершити його'; } diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index baef93e89ad..069b867c4fb 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -4901,7 +4901,7 @@ class AppLocalizationsUr extends AppLocalizations { 'جاری رکھ کر، آپ کی بات چیت، ریکارڈنگز اور ذاتی معلومات ہمارے سرورز پر محفوظ طریقے سے ذخیرہ کی جائیں گی۔ آپ کی آڈیو ریکارڈنگز اور ٹرانسکرپٹس تھرڈ پارٹی AI سروسز کے ذریعے پراسیس کی جاتی ہیں (بشمول ٹرانسکرپشن کے لیے Deepgram اور تجزیے کے لیے OpenAI) تاکہ آپ کو AI سے چلنے والی بصیرتیں فراہم کی جا سکیں اور ایپ کی تمام خصوصیات کو فعال کیا جا سکے۔'; @override - String get tasksEmptyStateMessage => 'ٹاسک بنانے کے لیے گفتگو شروع کریں۔'; + String get tasksEmptyStateMessage => 'آپ کی گفتگو سے کام یہاں ظاہر ہوں گے۔'; @override String get clearChatAction => 'بات کو صاف کریں'; @@ -13415,4 +13415,32 @@ class AppLocalizationsUr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ٹھیک کرنے کے لیے ٹیپ کریں'; + + @override + String get consentShortMessage => + 'Omi AI کی مدد سے آپ کی گفتگو کو نوٹس میں بدلتا ہے۔ آپ انہیں کسی بھی وقت حذف کر سکتے ہیں۔'; + + @override + String consentAiProviders(String providers) { + return '$providers کے ذریعے پروسیس کیا جاتا ہے۔'; + } + + @override + String get voiceProfileCardDismissed => 'آپ کسی بھی وقت سیٹنگز میں وائس پروفائل سیٹ کر سکتے ہیں۔'; + + @override + String get homeNewRecordingButton => 'نئی ریکارڈنگ'; + + @override + String get chatWelcomeMessage => + 'ہیلو، میں Omi ہوں۔ آپ نے جس بارے میں بات کی، اس کے بارے میں کچھ بھی پوچھیں: کسی نے کیا کہا، آپ نے کیا وعدہ کیا یا آپ کے دن کا خلاصہ۔ میں کام بھی شامل کر سکتا ہوں اور آپ کے لیے باتیں یاد بھی رکھ سکتا ہوں۔'; + + @override + String get onboardingStarterTaskAsk => 'Omi سے اپنے دن کے بارے میں پوچھیں'; + + @override + String get onboardingStarterTaskRecord => 'Omi کے ساتھ ایک گفتگو ریکارڈ کریں'; + + @override + String get onboardingStarterTaskCheck => 'مکمل کرنے کے لیے اس کام پر نشان لگائیں'; } diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index 8afcdb6b3c8..bf399d9f321 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -4903,7 +4903,7 @@ class AppLocalizationsVi extends AppLocalizations { 'Bằng cách tiếp tục, các cuộc trò chuyện, bản ghi âm và thông tin cá nhân của bạn sẽ được lưu trữ an toàn trên máy chủ của chúng tôi. Bản ghi âm và bản phiên âm của bạn được xử lý bởi các dịch vụ AI bên thứ ba (bao gồm Deepgram cho phiên âm và OpenAI cho phân tích) để cung cấp cho bạn thông tin chi tiết được hỗ trợ bởi AI và kích hoạt tất cả các tính năng ứng dụng.'; @override - String get tasksEmptyStateMessage => 'Bắt đầu cuộc trò chuyện để tạo nhiệm vụ.'; + String get tasksEmptyStateMessage => 'Việc cần làm từ các cuộc trò chuyện của bạn sẽ hiện ở đây.'; @override String get clearChatAction => 'Xóa cuộc trò chuyện'; @@ -13399,4 +13399,32 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Nhấn để khắc phục'; + + @override + String get consentShortMessage => + 'Omi dùng AI để biến các cuộc trò chuyện của bạn thành ghi chú. Bạn có thể xóa chúng bất cứ lúc nào.'; + + @override + String consentAiProviders(String providers) { + return 'Được xử lý bằng $providers.'; + } + + @override + String get voiceProfileCardDismissed => 'Bạn có thể thiết lập Hồ sơ giọng nói bất cứ lúc nào trong Cài đặt.'; + + @override + String get homeNewRecordingButton => 'Ghi âm mới'; + + @override + String get chatWelcomeMessage => + 'Chào bạn, mình là Omi. Hỏi mình bất cứ điều gì bạn đã nói: ai đã nói gì, bạn đã hứa gì hay tóm tắt ngày của bạn. Mình cũng có thể thêm việc cần làm và ghi nhớ mọi thứ giúp bạn.'; + + @override + String get onboardingStarterTaskAsk => 'Hỏi Omi về ngày của bạn'; + + @override + String get onboardingStarterTaskRecord => 'Ghi âm một cuộc trò chuyện với Omi'; + + @override + String get onboardingStarterTaskCheck => 'Đánh dấu việc này để hoàn thành'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index c3c34f915fd..7bfccb8244c 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -4809,7 +4809,7 @@ class AppLocalizationsZh extends AppLocalizations { '继续即表示您的对话、录音和个人信息将安全存储在我们的服务器上。您的音频录音和转录由第三方AI服务处理(包括用于转录的Deepgram和用于分析的OpenAI),以为您提供AI驱动的洞察并启用所有应用功能。'; @override - String get tasksEmptyStateMessage => '开始对话以创建任务。'; + String get tasksEmptyStateMessage => '对话中的待办事项会显示在这里。'; @override String get clearChatAction => '清除聊天'; @@ -13194,4 +13194,30 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboardingPermissionTapToFix => '点按以修复'; + + @override + String get consentShortMessage => 'Omi 用 AI 把你的对话整理成笔记。你可以随时删除。'; + + @override + String consentAiProviders(String providers) { + return '由 $providers 处理。'; + } + + @override + String get voiceProfileCardDismissed => '你可以随时在设置中设置声音档案。'; + + @override + String get homeNewRecordingButton => '新录音'; + + @override + String get chatWelcomeMessage => '你好,我是 Omi。关于你聊过的任何事都可以问我:某人说了什么、你答应过什么,或者今天的总结。我还能帮你添加待办、记住重要的事。'; + + @override + String get onboardingStarterTaskAsk => '问问 Omi 你今天过得怎么样'; + + @override + String get onboardingStarterTaskRecord => '用 Omi 录一段对话'; + + @override + String get onboardingStarterTaskCheck => '勾选这项任务来完成它'; } diff --git a/app/lib/l10n/app_lt.arb b/app/lib/l10n/app_lt.arb index b130c09ae05..dbb687186a8 100644 --- a/app/lib/l10n/app_lt.arb +++ b/app/lib/l10n/app_lt.arb @@ -1934,7 +1934,7 @@ "tasksAddedToList": "Užduotys bus pridėtos į šį sąrašą", "tasksCompleted": "Užduotys atliktos", "tasksCreatedInWorkspace": "Užduotys bus sukurtos šioje darbo srityje", - "tasksEmptyStateMessage": "Pradėkite pokalbį, kad sukurtumėte užduotį.", + "tasksEmptyStateMessage": "Užduotys iš jūsų pokalbių bus rodomos čia.", "tasksExportedOneApp": "Užduotys gali būti eksportuojamos į vieną programėlę vienu metu.", "tasksFromConversationsWillAppear": "Užduotys iš jūsų pokalbių bus rodomos čia.\nSpustelėkite Kurti, kad pridėtumėte vieną rankiniu būdu.", "tasksLater": "Vėliau", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Dar neturite Omi?", "onboardingOmiNotOrdered": "Dar neužsisakiau", "onboardingOmiOrdered": "Jau užsisakiau", - "onboardingPermissionTapToFix": "Bakstelėkite, kad pataisytumėte" + "onboardingPermissionTapToFix": "Bakstelėkite, kad pataisytumėte", + "consentShortMessage": "Omi naudoja DI, kad jūsų pokalbius paverstų užrašais. Juos galite ištrinti bet kada.", + "consentAiProviders": "Apdorojama su {providers}.", + "voiceProfileCardDismissed": "Balso profilį galite nustatyti bet kada nustatymuose.", + "homeNewRecordingButton": "Naujas įrašas", + "chatWelcomeMessage": "Labas, aš Omi. Klausk manęs apie bet ką, apie ką kalbėjai: ką kas nors pasakė, ką pažadėjai ar tavo dienos santrauką. Taip pat galiu pridėti užduočių ir prisiminti dalykus už tave.", + "onboardingStarterTaskAsk": "Paklausk Omi apie savo dieną", + "onboardingStarterTaskRecord": "Įrašyk pokalbį su Omi", + "onboardingStarterTaskCheck": "Pažymėk šią užduotį kaip atliktą" } diff --git a/app/lib/l10n/app_lv.arb b/app/lib/l10n/app_lv.arb index 5fd65ef7188..102a1199f42 100644 --- a/app/lib/l10n/app_lv.arb +++ b/app/lib/l10n/app_lv.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Ilgums: {seconds} sekundes", "additionalSpeechSampleRemoved": "Papildu balss paraugs noņemts", "consentDataMessage": "Turpinot, jūsu sarunas, ieraksti un personiskā informācija tiks droši glabāta mūsu serveros. Jūsu audio ieraksti un transkripcijas tiek apstrādātas ar trešo pušu AI pakalpojumiem (ieskaitot Deepgram transkripcijai un OpenAI analīzei), lai sniegtu jums AI vadītus ieskatus un iespējotu visas lietotnes funkcijas.", - "tasksEmptyStateMessage": "Sāciet sarunu, lai izveidotu uzdevumu.", + "tasksEmptyStateMessage": "Uzdevumi no jūsu sarunām parādīsies šeit.", "clearChatAction": "Notīrīt tērzēšanu", "enableApps": "Iespējot lietotnes", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Vēl nav Omi?", "onboardingOmiNotOrdered": "Vēl neesmu pasūtījis", "onboardingOmiOrdered": "Esmu to pasūtījis", - "onboardingPermissionTapToFix": "Pieskarieties, lai labotu" + "onboardingPermissionTapToFix": "Pieskarieties, lai labotu", + "consentShortMessage": "Omi ar MI palīdzību pārvērš jūsu sarunas piezīmēs. Tās var izdzēst jebkurā laikā.", + "consentAiProviders": "Apstrādā {providers}.", + "voiceProfileCardDismissed": "Balss profilu varat iestatīt jebkurā laikā iestatījumos.", + "homeNewRecordingButton": "Jauns ieraksts", + "chatWelcomeMessage": "Sveiki, es esmu Omi. Jautā man jebko par to, par ko esi runājis: ko kāds teica, ko tu apsolīji vai kopsavilkumu par tavu dienu. Es varu arī pievienot uzdevumus un atcerēties lietas tavā vietā.", + "onboardingStarterTaskAsk": "Pajautā Omi par savu dienu", + "onboardingStarterTaskRecord": "Ieraksti sarunu ar Omi", + "onboardingStarterTaskCheck": "Atzīmē šo uzdevumu, lai to pabeigtu" } diff --git a/app/lib/l10n/app_mk.arb b/app/lib/l10n/app_mk.arb index 026bf2632bb..306265ff523 100644 --- a/app/lib/l10n/app_mk.arb +++ b/app/lib/l10n/app_mk.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Започнете разговор за да создадете задача.", + "tasksEmptyStateMessage": "Задачите од вашите разговори ќе се појават тука.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Сè уште немате Omi?", "onboardingOmiNotOrdered": "Сè уште не сум нарачал", "onboardingOmiOrdered": "Го нарачав", - "onboardingPermissionTapToFix": "Допрете за да поправите" + "onboardingPermissionTapToFix": "Допрете за да поправите", + "consentShortMessage": "Omi користи вештачка интелигенција за да ги претвори вашите разговори во белешки. Можете да ги избришете во секое време.", + "consentAiProviders": "Се обработува со {providers}.", + "voiceProfileCardDismissed": "Гласовниот профил можете да го поставите во секое време во Поставки.", + "homeNewRecordingButton": "Ново снимање", + "chatWelcomeMessage": "Здраво, јас сум Omi. Прашај ме било што за она за што си зборувал: што рекол некој, што си ветил или резиме на твојот ден. Можам и да додавам задачи и да паметам работи за тебе.", + "onboardingStarterTaskAsk": "Прашај го Omi за твојот ден", + "onboardingStarterTaskRecord": "Сними разговор со Omi", + "onboardingStarterTaskCheck": "Штиклирај ја оваа задача за да ја завршиш" } diff --git a/app/lib/l10n/app_mr.arb b/app/lib/l10n/app_mr.arb index db0c8a2829a..49dd0c9ef64 100644 --- a/app/lib/l10n/app_mr.arb +++ b/app/lib/l10n/app_mr.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "कार्य तयार करण्यासाठी संभाषण सुरू करा.", + "tasksEmptyStateMessage": "तुमच्या संभाषणांमधील कामे इथे दिसतील.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "अजून तुमचा Omi आला नाही?", "onboardingOmiNotOrdered": "अजून ऑर्डर केलेला नाही", "onboardingOmiOrdered": "मी ऑर्डर केला आहे", - "onboardingPermissionTapToFix": "दुरुस्त करण्यासाठी टॅप करा" + "onboardingPermissionTapToFix": "दुरुस्त करण्यासाठी टॅप करा", + "consentShortMessage": "Omi AI वापरून तुमच्या संभाषणांच्या नोट्स बनवते. तुम्ही त्या कधीही हटवू शकता.", + "consentAiProviders": "{providers} द्वारे प्रक्रिया केली जाते.", + "voiceProfileCardDismissed": "सेटिंग्जमध्ये कधीही व्हॉइस प्रोफाइल सेट करू शकता.", + "homeNewRecordingButton": "नवीन रेकॉर्डिंग", + "chatWelcomeMessage": "नमस्कार, मी Omi आहे. तुम्ही ज्याबद्दल बोललात त्याबद्दल काहीही विचारा: कोणी काय सांगितले, तुम्ही काय वचन दिले किंवा तुमच्या दिवसाचा सारांश. मी कामे जोडू शकतो आणि तुमच्यासाठी गोष्टी लक्षातही ठेवू शकतो.", + "onboardingStarterTaskAsk": "Omi ला तुमच्या दिवसाबद्दल विचारा", + "onboardingStarterTaskRecord": "Omi ने एक संभाषण रेकॉर्ड करा", + "onboardingStarterTaskCheck": "पूर्ण करण्यासाठी हे काम टिक करा" } diff --git a/app/lib/l10n/app_ms.arb b/app/lib/l10n/app_ms.arb index f517f561fcb..abbd345d7f7 100644 --- a/app/lib/l10n/app_ms.arb +++ b/app/lib/l10n/app_ms.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Tempoh: {seconds} saat", "additionalSpeechSampleRemoved": "Sampel suara tambahan dibuang", "consentDataMessage": "Dengan meneruskan, perbualan, rakaman dan maklumat peribadi anda akan disimpan dengan selamat di pelayan kami. Rakaman audio dan transkrip anda diproses oleh perkhidmatan AI pihak ketiga (termasuk Deepgram untuk transkripsi dan OpenAI untuk analisis) untuk memberikan anda pandangan dikuasakan AI dan membolehkan semua ciri aplikasi.", - "tasksEmptyStateMessage": "Mulakan perbualan untuk mencipta tugasan.", + "tasksEmptyStateMessage": "Tugasan daripada perbualan anda akan dipaparkan di sini.", "clearChatAction": "Kosongkan sembang", "enableApps": "Dayakan aplikasi", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Belum ada Omi lagi?", "onboardingOmiNotOrdered": "Belum memesan", "onboardingOmiOrdered": "Saya sudah memesannya", - "onboardingPermissionTapToFix": "Ketik untuk membetulkan" + "onboardingPermissionTapToFix": "Ketik untuk membetulkan", + "consentShortMessage": "Omi menggunakan AI untuk menukar perbualan anda kepada nota. Anda boleh memadamkannya bila-bila masa.", + "consentAiProviders": "Diproses dengan {providers}.", + "voiceProfileCardDismissed": "Anda boleh menyediakan Profil Suara pada bila-bila masa dalam Tetapan.", + "homeNewRecordingButton": "Rakaman Baharu", + "chatWelcomeMessage": "Hai, saya Omi. Tanya saya apa sahaja tentang perkara yang anda bualkan: apa yang dikatakan seseorang, apa yang anda janjikan atau ringkasan hari anda. Saya juga boleh menambah tugasan dan mengingati sesuatu untuk anda.", + "onboardingStarterTaskAsk": "Tanya Omi tentang hari anda", + "onboardingStarterTaskRecord": "Rakam perbualan dengan Omi", + "onboardingStarterTaskCheck": "Tandakan tugasan ini untuk menyelesaikannya" } diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb index fcf1d5f6e66..5b43ccacf48 100644 --- a/app/lib/l10n/app_nl.arb +++ b/app/lib/l10n/app_nl.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Duur: {seconds} seconden", "additionalSpeechSampleRemoved": "Extra spraakvoorbeeld verwijderd", "consentDataMessage": "Door verder te gaan worden uw gesprekken, opnames en persoonlijke informatie veilig opgeslagen op onze servers. Uw audio-opnames en transcripties worden verwerkt door AI-diensten van derden (waaronder Deepgram voor transcriptie en OpenAI voor analyse) om u AI-gestuurde inzichten te bieden en alle app-functies mogelijk te maken.", - "tasksEmptyStateMessage": "Begin een gesprek om een taak aan te maken.", + "tasksEmptyStateMessage": "Taken uit je gesprekken verschijnen hier.", "clearChatAction": "Chat wissen", "enableApps": "Apps inschakelen", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Heb je je Omi nog niet?", "onboardingOmiNotOrdered": "Nog niet besteld", "onboardingOmiOrdered": "Ik heb hem besteld", - "onboardingPermissionTapToFix": "Tik om te herstellen" + "onboardingPermissionTapToFix": "Tik om te herstellen", + "consentShortMessage": "Omi maakt met AI notities van je gesprekken. Je kunt ze altijd verwijderen.", + "consentAiProviders": "Verwerkt met {providers}.", + "voiceProfileCardDismissed": "Je kunt je stemprofiel altijd instellen via Instellingen.", + "homeNewRecordingButton": "Nieuwe opname", + "chatWelcomeMessage": "Hoi, ik ben Omi. Vraag me alles over waar je het over hebt gehad: wat iemand zei, wat je beloofde of een samenvatting van je dag. Ik kan ook taken toevoegen en dingen voor je onthouden.", + "onboardingStarterTaskAsk": "Vraag Omi naar je dag", + "onboardingStarterTaskRecord": "Neem een gesprek op met Omi", + "onboardingStarterTaskCheck": "Vink deze taak af om hem af te ronden" } diff --git a/app/lib/l10n/app_no.arb b/app/lib/l10n/app_no.arb index 0afdfb22099..ea272a63d42 100644 --- a/app/lib/l10n/app_no.arb +++ b/app/lib/l10n/app_no.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Varighet: {seconds} sekunder", "additionalSpeechSampleRemoved": "Ekstra taleprøve fjernet", "consentDataMessage": "Ved å fortsette vil samtalene, opptakene og den personlige informasjonen din bli lagret sikkert på våre servere. Lydopptakene og transkripsjonene dine behandles av tredjeparts AI-tjenester (inkludert Deepgram for transkripsjon og OpenAI for analyse) for å gi deg AI-drevne innsikter og aktivere alle appfunksjoner.", - "tasksEmptyStateMessage": "Start en samtale for å opprette en oppgave.", + "tasksEmptyStateMessage": "Gjøremål fra samtalene dine vises her.", "clearChatAction": "Tøm chat", "enableApps": "Aktiver apper", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Har du ikke fått Omi ennå?", "onboardingOmiNotOrdered": "Ikke bestilt ennå", "onboardingOmiOrdered": "Jeg har bestilt den", - "onboardingPermissionTapToFix": "Trykk for å fikse" + "onboardingPermissionTapToFix": "Trykk for å fikse", + "consentShortMessage": "Omi bruker KI til å gjøre samtalene dine om til notater. Du kan slette dem når som helst.", + "consentAiProviders": "Behandles med {providers}.", + "voiceProfileCardDismissed": "Du kan sette opp stemmeprofilen når som helst i Innstillinger.", + "homeNewRecordingButton": "Ny opptak", + "chatWelcomeMessage": "Hei, jeg er Omi. Spør meg om alt du har snakket om: hva noen sa, hva du lovet, eller et sammendrag av dagen din. Jeg kan også legge til oppgaver og huske ting for deg.", + "onboardingStarterTaskAsk": "Spør Omi om dagen din", + "onboardingStarterTaskRecord": "Ta opp en samtale med Omi", + "onboardingStarterTaskCheck": "Kryss av denne oppgaven for å fullføre den" } diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index ac6f53ec3d1..2ed571dd44e 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Czas trwania: {seconds} sekund", "additionalSpeechSampleRemoved": "Dodatkowa próbka głosowa usunięta", "consentDataMessage": "Kontynuując, Twoje rozmowy, nagrania i dane osobowe będą bezpiecznie przechowywane na naszych serwerach. Twoje nagrania audio i transkrypcje są przetwarzane przez zewnętrzne usługi AI (w tym Deepgram do transkrypcji i OpenAI do analizy), aby dostarczyć Ci wglądy oparte na AI i umożliwić wszystkie funkcje aplikacji.", - "tasksEmptyStateMessage": "Rozpocznij rozmowę, aby utworzyć zadanie.", + "tasksEmptyStateMessage": "Zadania z Twoich rozmów pojawią się tutaj.", "clearChatAction": "Wyczyść czat", "enableApps": "Włącz aplikacje", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Nie masz jeszcze Omi?", "onboardingOmiNotOrdered": "Jeszcze nie zamówiłem/am", "onboardingOmiOrdered": "Już zamówiłem/am", - "onboardingPermissionTapToFix": "Dotknij, aby naprawić" + "onboardingPermissionTapToFix": "Dotknij, aby naprawić", + "consentShortMessage": "Omi za pomocą AI zamienia Twoje rozmowy w notatki. Możesz je usunąć w każdej chwili.", + "consentAiProviders": "Przetwarzane przez {providers}.", + "voiceProfileCardDismissed": "Profil głosowy możesz skonfigurować w każdej chwili w Ustawieniach.", + "homeNewRecordingButton": "Nowe nagranie", + "chatWelcomeMessage": "Cześć, jestem Omi. Pytaj mnie o wszystko, o czym rozmawiałeś: co ktoś powiedział, co obiecałeś albo jak minął ci dzień. Mogę też dodawać zadania i zapamiętywać rzeczy za ciebie.", + "onboardingStarterTaskAsk": "Zapytaj Omi o swój dzień", + "onboardingStarterTaskRecord": "Nagraj rozmowę z Omi", + "onboardingStarterTaskCheck": "Odhacz to zadanie, aby je ukończyć" } diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index 764de36b841..61758995794 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Duração: {seconds} segundos", "additionalSpeechSampleRemoved": "Amostra de voz adicional removida", "consentDataMessage": "Ao continuar, suas conversas, gravações e informações pessoais serão armazenadas com segurança em nossos servidores. Suas gravações de áudio e transcrições são processadas por serviços de IA de terceiros (incluindo Deepgram para transcrição e OpenAI para análise) para fornecer insights baseados em IA e habilitar todos os recursos do aplicativo.", - "tasksEmptyStateMessage": "Inicie uma conversa para criar uma tarefa.", + "tasksEmptyStateMessage": "As tarefas das suas conversas aparecerão aqui.", "clearChatAction": "Limpar conversa", "enableApps": "Ativar aplicativos", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Ainda não tem o seu Omi?", "onboardingOmiNotOrdered": "Ainda não encomendei", "onboardingOmiOrdered": "Já encomendei", - "onboardingPermissionTapToFix": "Toque para corrigir" + "onboardingPermissionTapToFix": "Toque para corrigir", + "consentShortMessage": "O Omi usa IA para transformar suas conversas em notas. Você pode excluí-las quando quiser.", + "consentAiProviders": "Processado com {providers}.", + "voiceProfileCardDismissed": "Você pode configurar o perfil de voz a qualquer momento em Ajustes.", + "homeNewRecordingButton": "Nova gravação", + "chatWelcomeMessage": "Oi, eu sou o Omi. Pergunte qualquer coisa sobre o que você conversou: o que alguém disse, o que você prometeu ou um resumo do seu dia. Também posso adicionar tarefas e lembrar coisas para você.", + "onboardingStarterTaskAsk": "Pergunte ao Omi sobre o seu dia", + "onboardingStarterTaskRecord": "Grave uma conversa com o Omi", + "onboardingStarterTaskCheck": "Marque esta tarefa para concluí-la" } diff --git a/app/lib/l10n/app_ro.arb b/app/lib/l10n/app_ro.arb index 980e58f3a52..374e5350ab9 100644 --- a/app/lib/l10n/app_ro.arb +++ b/app/lib/l10n/app_ro.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Durată: {seconds} secunde", "additionalSpeechSampleRemoved": "Mostră vocală suplimentară eliminată", "consentDataMessage": "Continuând, conversațiile, înregistrările și informațiile dvs. personale vor fi stocate în siguranță pe serverele noastre. Înregistrările audio și transcrierile dvs. sunt procesate de servicii AI terțe (inclusiv Deepgram pentru transcriere și OpenAI pentru analiză) pentru a vă oferi informații bazate pe AI și a activa toate funcțiile aplicației.", - "tasksEmptyStateMessage": "Începe o conversație pentru a crea o sarcină.", + "tasksEmptyStateMessage": "Sarcinile din conversațiile tale vor apărea aici.", "clearChatAction": "Șterge conversația", "enableApps": "Activează aplicațiile", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Nu ai încă Omi?", "onboardingOmiNotOrdered": "Încă nu am comandat", "onboardingOmiOrdered": "L-am comandat", - "onboardingPermissionTapToFix": "Atinge pentru a remedia" + "onboardingPermissionTapToFix": "Atinge pentru a remedia", + "consentShortMessage": "Omi folosește AI pentru a transforma conversațiile tale în notițe. Le poți șterge oricând.", + "consentAiProviders": "Procesat cu {providers}.", + "voiceProfileCardDismissed": "Poți configura profilul vocal oricând din Setări.", + "homeNewRecordingButton": "Înregistrare nouă", + "chatWelcomeMessage": "Salut, sunt Omi. Întreabă-mă orice despre ce ai vorbit: ce a spus cineva, ce ai promis sau un rezumat al zilei tale. Pot și să adaug sarcini și să țin minte lucruri pentru tine.", + "onboardingStarterTaskAsk": "Întreabă-l pe Omi despre ziua ta", + "onboardingStarterTaskRecord": "Înregistrează o conversație cu Omi", + "onboardingStarterTaskCheck": "Bifează această sarcină ca s-o finalizezi" } diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 0d551fef472..35655b9aa25 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Длительность: {seconds} секунд", "additionalSpeechSampleRemoved": "Дополнительный образец голоса удален", "consentDataMessage": "Продолжая, ваши разговоры, записи и личная информация будут надежно храниться на наших серверах. Ваши аудиозаписи и транскрипции обрабатываются сторонними AI-сервисами (включая Deepgram для транскрипции и OpenAI для анализа), чтобы предоставить вам аналитику на основе ИИ и обеспечить работу всех функций приложения.", - "tasksEmptyStateMessage": "Начните разговор, чтобы создать задачу.", + "tasksEmptyStateMessage": "Задачи из ваших разговоров появятся здесь.", "clearChatAction": "Очистить чат", "enableApps": "Включить приложения", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Ещё нет Omi?", "onboardingOmiNotOrdered": "Ещё не заказал", "onboardingOmiOrdered": "Я уже заказал", - "onboardingPermissionTapToFix": "Нажмите, чтобы исправить" + "onboardingPermissionTapToFix": "Нажмите, чтобы исправить", + "consentShortMessage": "Omi с помощью ИИ превращает ваши разговоры в заметки. Их можно удалить в любой момент.", + "consentAiProviders": "Обработка: {providers}.", + "voiceProfileCardDismissed": "Голосовой профиль можно настроить в любое время в Настройках.", + "homeNewRecordingButton": "Новая запись", + "chatWelcomeMessage": "Привет, я Omi. Спрашивайте обо всём, о чём вы говорили: что сказал кто-то, что вы пообещали или как прошёл ваш день. Я также могу добавлять задачи и запоминать важное за вас.", + "onboardingStarterTaskAsk": "Спросите Omi о своём дне", + "onboardingStarterTaskRecord": "Запишите разговор с Omi", + "onboardingStarterTaskCheck": "Отметьте эту задачу, чтобы завершить её" } diff --git a/app/lib/l10n/app_sk.arb b/app/lib/l10n/app_sk.arb index b71b8de21e3..7025102999d 100644 --- a/app/lib/l10n/app_sk.arb +++ b/app/lib/l10n/app_sk.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Trvanie: {seconds} sekúnd", "additionalSpeechSampleRemoved": "Ďalšia hlasová vzorka odstránená", "consentDataMessage": "Pokračovaním budú vaše konverzácie, nahrávky a osobné údaje bezpečne uložené na našich serveroch. Vaše audio nahrávky a prepisy sú spracovávané AI službami tretích strán (vrátane Deepgram na prepis a OpenAI na analýzu), aby vám poskytli poznatky založené na AI a umožnili všetky funkcie aplikácie.", - "tasksEmptyStateMessage": "Začnite konverzáciu a vytvorte úlohu.", + "tasksEmptyStateMessage": "Úlohy z vašich rozhovorov sa zobrazia tu.", "clearChatAction": "Vymazať chat", "enableApps": "Povoliť aplikácie", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Ešte nemáte Omi?", "onboardingOmiNotOrdered": "Ešte neobjednané", "onboardingOmiOrdered": "Už som ho objednal/a", - "onboardingPermissionTapToFix": "Ťuknutím opravíte" + "onboardingPermissionTapToFix": "Ťuknutím opravíte", + "consentShortMessage": "Omi pomocou AI mení vaše konverzácie na poznámky. Kedykoľvek ich môžete vymazať.", + "consentAiProviders": "Spracúva {providers}.", + "voiceProfileCardDismissed": "Hlasový profil si môžete kedykoľvek nastaviť v Nastaveniach.", + "homeNewRecordingButton": "Nový záznam", + "chatWelcomeMessage": "Ahoj, som Omi. Opýtaj sa ma na čokoľvek, o čom si hovoril: čo kto povedal, čo si sľúbil alebo zhrnutie tvojho dňa. Viem aj pridávať úlohy a pamätať si veci za teba.", + "onboardingStarterTaskAsk": "Opýtaj sa Omi na svoj deň", + "onboardingStarterTaskRecord": "Nahraj rozhovor s Omi", + "onboardingStarterTaskCheck": "Odškrtni túto úlohu a dokonči ju" } diff --git a/app/lib/l10n/app_sl.arb b/app/lib/l10n/app_sl.arb index 07e97b7e0c7..6b8c98908f3 100644 --- a/app/lib/l10n/app_sl.arb +++ b/app/lib/l10n/app_sl.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Začnite pogovor, da ustvarite nalogo.", + "tasksEmptyStateMessage": "Opravila iz vaših pogovorov se bodo prikazala tukaj.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Še nimate Omi?", "onboardingOmiNotOrdered": "Še nisem naročil/a", "onboardingOmiOrdered": "Naročil/a sem ga", - "onboardingPermissionTapToFix": "Tapnite za popravilo" + "onboardingPermissionTapToFix": "Tapnite za popravilo", + "consentShortMessage": "Omi z umetno inteligenco spremeni vaše pogovore v zapiske. Kadar koli jih lahko izbrišete.", + "consentAiProviders": "Obdelano z {providers}.", + "voiceProfileCardDismissed": "Glasovni profil lahko kadar koli nastavite v Nastavitvah.", + "homeNewRecordingButton": "Novo snemanje", + "chatWelcomeMessage": "Živjo, sem Omi. Vprašaj me karkoli o tem, o čemer si govoril: kaj je kdo rekel, kaj si obljubil ali povzetek tvojega dne. Lahko tudi dodajam opravila in si zapomnim stvari namesto tebe.", + "onboardingStarterTaskAsk": "Vprašaj Omi o svojem dnevu", + "onboardingStarterTaskRecord": "Posnemi pogovor z Omi", + "onboardingStarterTaskCheck": "Odkljukaj to opravilo, da ga končaš" } diff --git a/app/lib/l10n/app_sr.arb b/app/lib/l10n/app_sr.arb index 4bad08d7ffb..987d5eda91e 100644 --- a/app/lib/l10n/app_sr.arb +++ b/app/lib/l10n/app_sr.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Започните разговор да бисте направили задатак.", + "tasksEmptyStateMessage": "Задаци из ваших разговора појавиће се овде.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Још немате Omi?", "onboardingOmiNotOrdered": "Још нисам наручио/ла", "onboardingOmiOrdered": "Наручио/ла сам га", - "onboardingPermissionTapToFix": "Додирните да поправите" + "onboardingPermissionTapToFix": "Додирните да поправите", + "consentShortMessage": "Omi користи AI да ваше разговоре претвори у белешке. Можете их обрисати у било ком тренутку.", + "consentAiProviders": "Обрађује се помоћу {providers}.", + "voiceProfileCardDismissed": "Гласовни профил можете подесити било када у Подешавањима.", + "homeNewRecordingButton": "Ново снимање", + "chatWelcomeMessage": "Zdravo, ja sam Omi. Pitaj me bilo šta o čemu si pričao: šta je neko rekao, šta si obećao ili rezime tvog dana. Mogu i da dodajem zadatke i pamtim stvari za tebe.", + "onboardingStarterTaskAsk": "Pitaj Omi o svom danu", + "onboardingStarterTaskRecord": "Snimi razgovor uz Omi", + "onboardingStarterTaskCheck": "Označi ovaj zadatak da ga završiš" } diff --git a/app/lib/l10n/app_sv.arb b/app/lib/l10n/app_sv.arb index 7532930c3ab..13822bbfb92 100644 --- a/app/lib/l10n/app_sv.arb +++ b/app/lib/l10n/app_sv.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Längd: {seconds} sekunder", "additionalSpeechSampleRemoved": "Ytterligare röstprov borttaget", "consentDataMessage": "Genom att fortsätta kommer dina konversationer, inspelningar och personlig information att lagras säkert på våra servrar. Dina ljudinspelningar och transkriptioner behandlas av AI-tjänster från tredje part (inklusive Deepgram för transkription och OpenAI för analys) för att ge dig AI-drivna insikter och aktivera alla appfunktioner.", - "tasksEmptyStateMessage": "Starta ett samtal för att skapa en uppgift.", + "tasksEmptyStateMessage": "Att göra från dina samtal visas här.", "clearChatAction": "Rensa chatt", "enableApps": "Aktivera appar", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Har du inte fått din Omi än?", "onboardingOmiNotOrdered": "Inte beställt än", "onboardingOmiOrdered": "Jag har beställt den", - "onboardingPermissionTapToFix": "Tryck för att åtgärda" + "onboardingPermissionTapToFix": "Tryck för att åtgärda", + "consentShortMessage": "Omi använder AI för att göra dina samtal till anteckningar. Du kan radera dem när som helst.", + "consentAiProviders": "Bearbetas med {providers}.", + "voiceProfileCardDismissed": "Du kan ställa in din röstprofil när som helst i Inställningar.", + "homeNewRecordingButton": "Ny inspelning", + "chatWelcomeMessage": "Hej, jag är Omi. Fråga mig om allt du har pratat om: vad någon sa, vad du lovade eller en sammanfattning av din dag. Jag kan också lägga till uppgifter och komma ihåg saker åt dig.", + "onboardingStarterTaskAsk": "Fråga Omi om din dag", + "onboardingStarterTaskRecord": "Spela in ett samtal med Omi", + "onboardingStarterTaskCheck": "Bocka av den här uppgiften för att slutföra den" } diff --git a/app/lib/l10n/app_ta.arb b/app/lib/l10n/app_ta.arb index cd6cacde5fa..f840f3001bb 100644 --- a/app/lib/l10n/app_ta.arb +++ b/app/lib/l10n/app_ta.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "ஒரு பணியை உருவாக்க உரையாடலைத் தொடங்குங்கள்.", + "tasksEmptyStateMessage": "உங்கள் உரையாடல்களில் உள்ள பணிகள் இங்கே தோன்றும்.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "இன்னும் உங்கள் Omi இல்லையா?", "onboardingOmiNotOrdered": "இன்னும் ஆர்டர் செய்யவில்லை", "onboardingOmiOrdered": "நான் ஆர்டர் செய்துவிட்டேன்", - "onboardingPermissionTapToFix": "சரிசெய்ய தட்டவும்" + "onboardingPermissionTapToFix": "சரிசெய்ய தட்டவும்", + "consentShortMessage": "Omi AI மூலம் உங்கள் உரையாடல்களைக் குறிப்புகளாக மாற்றுகிறது. எப்போது வேண்டுமானாலும் நீக்கலாம்.", + "consentAiProviders": "{providers} மூலம் செயலாக்கப்படுகிறது.", + "voiceProfileCardDismissed": "அமைப்புகளில் எப்போது வேண்டுமானாலும் குரல் சுயவிவரத்தை அமைக்கலாம்.", + "homeNewRecordingButton": "புதிய பதிவு", + "chatWelcomeMessage": "வணக்கம், நான் Omi. நீங்கள் பேசிய எதைப் பற்றியும் கேளுங்கள்: யார் என்ன சொன்னார்கள், நீங்கள் என்ன வாக்குறுதி அளித்தீர்கள் அல்லது உங்கள் நாளின் சுருக்கம். பணிகளைச் சேர்க்கவும் உங்களுக்காக விஷயங்களை நினைவில் வைக்கவும் என்னால் முடியும்.", + "onboardingStarterTaskAsk": "உங்கள் நாளைப் பற்றி Omi-யிடம் கேளுங்கள்", + "onboardingStarterTaskRecord": "Omi மூலம் ஒரு உரையாடலைப் பதிவுசெய்யுங்கள்", + "onboardingStarterTaskCheck": "முடிக்க இந்தப் பணியைக் குறியிடுங்கள்" } diff --git a/app/lib/l10n/app_te.arb b/app/lib/l10n/app_te.arb index 8802ec884b9..ecf63c2e2c4 100644 --- a/app/lib/l10n/app_te.arb +++ b/app/lib/l10n/app_te.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "పనిని సృష్టించడానికి సంభాషణను ప్రారంభించండి.", + "tasksEmptyStateMessage": "మీ సంభాషణల నుండి పనులు ఇక్కడ కనిపిస్తాయి.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "ఇంకా మీ Omi లేదా?", "onboardingOmiNotOrdered": "ఇంకా ఆర్డర్ చేయలేదు", "onboardingOmiOrdered": "నేను ఆర్డర్ చేశాను", - "onboardingPermissionTapToFix": "సరిచేయడానికి నొక్కండి" + "onboardingPermissionTapToFix": "సరిచేయడానికి నొక్కండి", + "consentShortMessage": "Omi AI సహాయంతో మీ సంభాషణలను నోట్స్‌గా మారుస్తుంది. వాటిని ఎప్పుడైనా తొలగించవచ్చు.", + "consentAiProviders": "{providers} ద్వారా ప్రాసెస్ చేయబడుతుంది.", + "voiceProfileCardDismissed": "సెట్టింగ్‌లలో ఎప్పుడైనా వాయిస్ ప్రొఫైల్ సెటప్ చేయవచ్చు.", + "homeNewRecordingButton": "కొత్త రికార్డింగ్", + "chatWelcomeMessage": "హాయ్, నేను Omi. మీరు మాట్లాడిన దేని గురించైనా అడగండి: ఎవరు ఏమన్నారు, మీరు ఏమి మాట ఇచ్చారు లేదా మీ రోజు సారాంశం. నేను పనులను జోడించగలను, మీ కోసం విషయాలు గుర్తుంచుకోగలను.", + "onboardingStarterTaskAsk": "మీ రోజు గురించి Omi ని అడగండి", + "onboardingStarterTaskRecord": "Omi తో ఒక సంభాషణను రికార్డ్ చేయండి", + "onboardingStarterTaskCheck": "పూర్తి చేయడానికి ఈ పనిని టిక్ చేయండి" } diff --git a/app/lib/l10n/app_th.arb b/app/lib/l10n/app_th.arb index 5d521f5f1de..95d492e4495 100644 --- a/app/lib/l10n/app_th.arb +++ b/app/lib/l10n/app_th.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "ระยะเวลา: {seconds} วินาที", "additionalSpeechSampleRemoved": "ลบตัวอย่างเสียงเพิ่มเติมแล้ว", "consentDataMessage": "เมื่อดำเนินการต่อ การสนทนา การบันทึก และข้อมูลส่วนบุคคลของคุณจะถูกจัดเก็บอย่างปลอดภัยบนเซิร์ฟเวอร์ของเรา การบันทึกเสียงและการถอดความของคุณจะถูกประมวลผลโดยบริการ AI ของบุคคลที่สาม (รวมถึง Deepgram สำหรับการถอดความ และ OpenAI สำหรับการวิเคราะห์) เพื่อมอบข้อมูลเชิงลึกที่ขับเคลื่อนด้วย AI และเปิดใช้งานคุณสมบัติทั้งหมดของแอป", - "tasksEmptyStateMessage": "เริ่มการสนทนาเพื่อสร้างงาน", + "tasksEmptyStateMessage": "สิ่งที่ต้องทำจากบทสนทนาของคุณจะแสดงที่นี่", "clearChatAction": "ล้างแชท", "enableApps": "เปิดใช้งานแอป", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "ยังไม่มี Omi ใช่ไหม", "onboardingOmiNotOrdered": "ยังไม่ได้สั่งซื้อ", "onboardingOmiOrdered": "สั่งซื้อแล้ว", - "onboardingPermissionTapToFix": "แตะเพื่อแก้ไข" + "onboardingPermissionTapToFix": "แตะเพื่อแก้ไข", + "consentShortMessage": "Omi ใช้ AI เปลี่ยนบทสนทนาของคุณเป็นโน้ต คุณลบได้ทุกเมื่อ", + "consentAiProviders": "ประมวลผลด้วย {providers}", + "voiceProfileCardDismissed": "คุณตั้งค่าโปรไฟล์เสียงได้ทุกเมื่อในการตั้งค่า", + "homeNewRecordingButton": "บันทึกใหม่", + "chatWelcomeMessage": "สวัสดี ฉันคือ Omi ถามอะไรก็ได้เกี่ยวกับสิ่งที่คุณคุยไว้ เช่น ใครพูดอะไร คุณสัญญาอะไรไว้ หรือสรุปวันของคุณ ฉันยังเพิ่มสิ่งที่ต้องทำและจำเรื่องต่าง ๆ ให้คุณได้ด้วย", + "onboardingStarterTaskAsk": "ถาม Omi เกี่ยวกับวันของคุณ", + "onboardingStarterTaskRecord": "บันทึกบทสนทนาด้วย Omi", + "onboardingStarterTaskCheck": "ติ๊กงานนี้เพื่อทำให้เสร็จ" } diff --git a/app/lib/l10n/app_tl.arb b/app/lib/l10n/app_tl.arb index 7dbb649a64c..7c7aeb01475 100644 --- a/app/lib/l10n/app_tl.arb +++ b/app/lib/l10n/app_tl.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "Magsimula ng pag-uusap para gumawa ng gawain.", + "tasksEmptyStateMessage": "Lalabas dito ang mga gagawin mula sa iyong mga usapan.", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "Wala ka pang Omi?", "onboardingOmiNotOrdered": "Hindi pa naka-order", "onboardingOmiOrdered": "Na-order ko na", - "onboardingPermissionTapToFix": "I-tap para ayusin" + "onboardingPermissionTapToFix": "I-tap para ayusin", + "consentShortMessage": "Ginagamit ng Omi ang AI para gawing notes ang iyong mga usapan. Puwede mo itong burahin anumang oras.", + "consentAiProviders": "Pinoproseso gamit ang {providers}.", + "voiceProfileCardDismissed": "Maaari mong i-set up ang Voice Profile anumang oras sa Settings.", + "homeNewRecordingButton": "Bagong Recording", + "chatWelcomeMessage": "Hi, ako si Omi. Itanong mo sa akin ang kahit ano tungkol sa mga napag-usapan mo: ang sinabi ng isang tao, ang ipinangako mo, o buod ng araw mo. Kaya ko ring magdagdag ng mga gawain at tandaan ang mga bagay para sa iyo.", + "onboardingStarterTaskAsk": "Tanungin si Omi tungkol sa araw mo", + "onboardingStarterTaskRecord": "Mag-record ng usapan gamit ang Omi", + "onboardingStarterTaskCheck": "Lagyan ng tsek ang gawaing ito para matapos" } diff --git a/app/lib/l10n/app_tr.arb b/app/lib/l10n/app_tr.arb index 7bb47cd0a08..136a98c2f9a 100644 --- a/app/lib/l10n/app_tr.arb +++ b/app/lib/l10n/app_tr.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Süre: {seconds} saniye", "additionalSpeechSampleRemoved": "Ek ses örneği kaldırıldı", "consentDataMessage": "Devam ederek, konuşmalarınız, kayıtlarınız ve kişisel bilgileriniz sunucularımızda güvenli bir şekilde saklanacaktır. Ses kayıtlarınız ve transkriptleriniz, size yapay zeka destekli içgörüler sağlamak ve tüm uygulama özelliklerini etkinleştirmek için üçüncü taraf yapay zeka hizmetleri (transkripsiyon için Deepgram ve analiz için OpenAI dahil) tarafından işlenir.", - "tasksEmptyStateMessage": "Görev oluşturmak için bir konuşma başlatın.", + "tasksEmptyStateMessage": "Konuşmalarındaki yapılacaklar burada görünecek.", "clearChatAction": "Sohbeti temizle", "enableApps": "Uygulamaları etkinleştir", "omiAppName": "Omi", @@ -4150,5 +4150,13 @@ "onboardingNoOmiYet": "Omi'n henüz yok mu?", "onboardingOmiNotOrdered": "Henüz sipariş vermedim", "onboardingOmiOrdered": "Sipariş verdim", - "onboardingPermissionTapToFix": "Düzeltmek için dokunun" + "onboardingPermissionTapToFix": "Düzeltmek için dokunun", + "consentShortMessage": "Omi, konuşmalarını yapay zekâ ile notlara dönüştürür. İstediğin zaman silebilirsin.", + "consentAiProviders": "{providers} ile işlenir.", + "voiceProfileCardDismissed": "Ses profilini istediğin zaman Ayarlar'dan kurabilirsin.", + "homeNewRecordingButton": "Yeni Kayıt", + "chatWelcomeMessage": "Merhaba, ben Omi. Konuştuğun her şeyi bana sorabilirsin: birinin ne dediğini, ne söz verdiğini ya da gününün özetini. Görev de ekleyebilir ve senin için bir şeyleri hatırlayabilirim.", + "onboardingStarterTaskAsk": "Omi'ye gününü sor", + "onboardingStarterTaskRecord": "Omi ile bir konuşma kaydet", + "onboardingStarterTaskCheck": "Bu görevi tamamlamak için işaretle" } diff --git a/app/lib/l10n/app_uk.arb b/app/lib/l10n/app_uk.arb index d1ee8d82086..f63e1587512 100644 --- a/app/lib/l10n/app_uk.arb +++ b/app/lib/l10n/app_uk.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Тривалість: {seconds} секунд", "additionalSpeechSampleRemoved": "Додатковий зразок голосу видалено", "consentDataMessage": "Продовжуючи, ваші розмови, записи та особиста інформація будуть надійно зберігатися на наших серверах. Ваші аудіозаписи та транскрипції обробляються сторонніми AI-сервісами (включаючи Deepgram для транскрипції та OpenAI для аналізу), щоб надати вам аналітику на основі ШІ та увімкнути всі функції додатку.", - "tasksEmptyStateMessage": "Почніть розмову, щоб створити завдання.", + "tasksEmptyStateMessage": "Завдання з ваших розмов з'являться тут.", "clearChatAction": "Очистити чат", "enableApps": "Увімкнути додатки", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Ще немає Omi?", "onboardingOmiNotOrdered": "Ще не замовив", "onboardingOmiOrdered": "Я вже замовив", - "onboardingPermissionTapToFix": "Торкніться, щоб виправити" + "onboardingPermissionTapToFix": "Торкніться, щоб виправити", + "consentShortMessage": "Omi за допомогою ШІ перетворює ваші розмови на нотатки. Їх можна видалити будь-коли.", + "consentAiProviders": "Обробка: {providers}.", + "voiceProfileCardDismissed": "Голосовий профіль можна налаштувати будь-коли в Налаштуваннях.", + "homeNewRecordingButton": "Новий запис", + "chatWelcomeMessage": "Привіт, я Omi. Питайте про все, про що ви говорили: що хтось сказав, що ви пообіцяли або як минув ваш день. Я також можу додавати завдання й запам'ятовувати важливе за вас.", + "onboardingStarterTaskAsk": "Запитайте Omi про свій день", + "onboardingStarterTaskRecord": "Запишіть розмову з Omi", + "onboardingStarterTaskCheck": "Позначте це завдання, щоб завершити його" } diff --git a/app/lib/l10n/app_ur.arb b/app/lib/l10n/app_ur.arb index 73db9e9b5f5..d7092aaeecb 100644 --- a/app/lib/l10n/app_ur.arb +++ b/app/lib/l10n/app_ur.arb @@ -5782,7 +5782,7 @@ "@consentDataMessage": { "description": "Consent message explaining how user data will be stored and used" }, - "tasksEmptyStateMessage": "ٹاسک بنانے کے لیے گفتگو شروع کریں۔", + "tasksEmptyStateMessage": "آپ کی گفتگو سے کام یہاں ظاہر ہوں گے۔", "@tasksEmptyStateMessage": { "description": "Empty state message shown when there are no tasks, with instruction to tap + button" }, @@ -11541,5 +11541,13 @@ "onboardingNoOmiYet": "ابھی تک Omi نہیں ہے؟", "onboardingOmiNotOrdered": "ابھی آرڈر نہیں کیا", "onboardingOmiOrdered": "میں نے آرڈر کر دیا ہے", - "onboardingPermissionTapToFix": "ٹھیک کرنے کے لیے ٹیپ کریں" + "onboardingPermissionTapToFix": "ٹھیک کرنے کے لیے ٹیپ کریں", + "consentShortMessage": "Omi AI کی مدد سے آپ کی گفتگو کو نوٹس میں بدلتا ہے۔ آپ انہیں کسی بھی وقت حذف کر سکتے ہیں۔", + "consentAiProviders": "{providers} کے ذریعے پروسیس کیا جاتا ہے۔", + "voiceProfileCardDismissed": "آپ کسی بھی وقت سیٹنگز میں وائس پروفائل سیٹ کر سکتے ہیں۔", + "homeNewRecordingButton": "نئی ریکارڈنگ", + "chatWelcomeMessage": "ہیلو، میں Omi ہوں۔ آپ نے جس بارے میں بات کی، اس کے بارے میں کچھ بھی پوچھیں: کسی نے کیا کہا، آپ نے کیا وعدہ کیا یا آپ کے دن کا خلاصہ۔ میں کام بھی شامل کر سکتا ہوں اور آپ کے لیے باتیں یاد بھی رکھ سکتا ہوں۔", + "onboardingStarterTaskAsk": "Omi سے اپنے دن کے بارے میں پوچھیں", + "onboardingStarterTaskRecord": "Omi کے ساتھ ایک گفتگو ریکارڈ کریں", + "onboardingStarterTaskCheck": "مکمل کرنے کے لیے اس کام پر نشان لگائیں" } diff --git a/app/lib/l10n/app_vi.arb b/app/lib/l10n/app_vi.arb index bd619b545f6..ddcef38955a 100644 --- a/app/lib/l10n/app_vi.arb +++ b/app/lib/l10n/app_vi.arb @@ -1501,7 +1501,7 @@ "durationSeconds": "Thời lượng: {seconds} giây", "additionalSpeechSampleRemoved": "Đã xóa mẫu giọng nói bổ sung", "consentDataMessage": "Bằng cách tiếp tục, các cuộc trò chuyện, bản ghi âm và thông tin cá nhân của bạn sẽ được lưu trữ an toàn trên máy chủ của chúng tôi. Bản ghi âm và bản phiên âm của bạn được xử lý bởi các dịch vụ AI bên thứ ba (bao gồm Deepgram cho phiên âm và OpenAI cho phân tích) để cung cấp cho bạn thông tin chi tiết được hỗ trợ bởi AI và kích hoạt tất cả các tính năng ứng dụng.", - "tasksEmptyStateMessage": "Bắt đầu cuộc trò chuyện để tạo nhiệm vụ.", + "tasksEmptyStateMessage": "Việc cần làm từ các cuộc trò chuyện của bạn sẽ hiện ở đây.", "clearChatAction": "Xóa cuộc trò chuyện", "enableApps": "Kích hoạt ứng dụng", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "Bạn chưa có Omi?", "onboardingOmiNotOrdered": "Chưa đặt hàng", "onboardingOmiOrdered": "Tôi đã đặt hàng", - "onboardingPermissionTapToFix": "Nhấn để khắc phục" + "onboardingPermissionTapToFix": "Nhấn để khắc phục", + "consentShortMessage": "Omi dùng AI để biến các cuộc trò chuyện của bạn thành ghi chú. Bạn có thể xóa chúng bất cứ lúc nào.", + "consentAiProviders": "Được xử lý bằng {providers}.", + "voiceProfileCardDismissed": "Bạn có thể thiết lập Hồ sơ giọng nói bất cứ lúc nào trong Cài đặt.", + "homeNewRecordingButton": "Ghi âm mới", + "chatWelcomeMessage": "Chào bạn, mình là Omi. Hỏi mình bất cứ điều gì bạn đã nói: ai đã nói gì, bạn đã hứa gì hay tóm tắt ngày của bạn. Mình cũng có thể thêm việc cần làm và ghi nhớ mọi thứ giúp bạn.", + "onboardingStarterTaskAsk": "Hỏi Omi về ngày của bạn", + "onboardingStarterTaskRecord": "Ghi âm một cuộc trò chuyện với Omi", + "onboardingStarterTaskCheck": "Đánh dấu việc này để hoàn thành" } diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index dfb86b2b5d9..d510aa3d7e8 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -1504,7 +1504,7 @@ "durationSeconds": "时长: {seconds} 秒", "additionalSpeechSampleRemoved": "已删除附加语音样本", "consentDataMessage": "继续即表示您的对话、录音和个人信息将安全存储在我们的服务器上。您的音频录音和转录由第三方AI服务处理(包括用于转录的Deepgram和用于分析的OpenAI),以为您提供AI驱动的洞察并启用所有应用功能。", - "tasksEmptyStateMessage": "开始对话以创建任务。", + "tasksEmptyStateMessage": "对话中的待办事项会显示在这里。", "clearChatAction": "清除聊天", "enableApps": "启用应用", "omiAppName": "Omi", @@ -4115,5 +4115,13 @@ "onboardingNoOmiYet": "还没有 Omi?", "onboardingOmiNotOrdered": "还没下单", "onboardingOmiOrdered": "我已下单", - "onboardingPermissionTapToFix": "点按以修复" + "onboardingPermissionTapToFix": "点按以修复", + "consentShortMessage": "Omi 用 AI 把你的对话整理成笔记。你可以随时删除。", + "consentAiProviders": "由 {providers} 处理。", + "voiceProfileCardDismissed": "你可以随时在设置中设置声音档案。", + "homeNewRecordingButton": "新录音", + "chatWelcomeMessage": "你好,我是 Omi。关于你聊过的任何事都可以问我:某人说了什么、你答应过什么,或者今天的总结。我还能帮你添加待办、记住重要的事。", + "onboardingStarterTaskAsk": "问问 Omi 你今天过得怎么样", + "onboardingStarterTaskRecord": "用 Omi 录一段对话", + "onboardingStarterTaskCheck": "勾选这项任务来完成它" } diff --git a/app/lib/mobile/mobile_app.dart b/app/lib/mobile/mobile_app.dart index 33e210dfdd9..370b2efce2c 100644 --- a/app/lib/mobile/mobile_app.dart +++ b/app/lib/mobile/mobile_app.dart @@ -4,7 +4,6 @@ import 'package:provider/provider.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/pages/home/page.dart'; -import 'package:omi/pages/onboarding/device_selection.dart'; import 'package:omi/pages/onboarding/permissions/permissions_checker.dart'; import 'package:omi/pages/onboarding/wrapper.dart'; import 'package:omi/providers/auth_provider.dart'; @@ -72,7 +71,8 @@ class _MobileAppState extends State { }, ); } else { - return const DeviceSelectionPage(); + // Signed out: straight to sign-in, the first onboarding step. + return const OnboardingWrapper(); } }, ); diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index 39b0119cd49..0aaa44537ca 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -2,7 +2,6 @@ import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:pull_down_button/pull_down_button.dart'; import 'package:omi/backend/http/action_items_api_contract.dart'; import 'package:omi/backend/http/api_presentation.dart'; @@ -59,8 +58,13 @@ class _ActionItemsPageState extends State with AutomaticKeepAli bool _noDeadlineExpanded = true; - /// Group the list by project instead of by due date (offered once projects exist). - bool _groupByProject = false; + /// Tasks just ticked (or reopened) that are about to leave this view: shown in their new state + /// at once ([_completing] maps id → new completed value), then folded away ([_folding]) before the + /// provider moves them, so the change reads as an action instead of a row vanishing. + final Map _completing = {}; + final Set _folding = {}; + static const _showDone = Duration(milliseconds: 450); + static const _fold = Duration(milliseconds: 320); // Search header lifecycle objects. final TextEditingController _searchController = TextEditingController(); @@ -199,7 +203,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli Widget _buildPageHeader(ActionItemsProvider provider) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 12, 4), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Row( children: [ Expanded( @@ -219,76 +223,11 @@ class _ActionItemsPageState extends State with AutomaticKeepAli }, ), ), - const SizedBox(width: 4), - _buildOverflowMenu(provider), ], ), ); } - Widget _buildOverflowMenu(ActionItemsProvider provider) { - final showingCompleted = provider.showCompletedView; - final hasItems = provider.actionItems.isNotEmpty; - final allSelected = hasItems && provider.selectedCount == provider.actionItems.length; - - return PullDownButton( - itemBuilder: (context) => [ - PullDownMenuItem( - title: context.l10n.selectActionItems, - iconWidget: const Icon(Icons.check_box_outlined, size: 18), - onTap: () { - OmiHaptics.light(); - _searchFocusNode.unfocus(); - provider.startSelection(); - }, - ), - PullDownMenuItem( - title: allSelected ? context.l10n.deselectAllTasksMenu : context.l10n.selectAllTasksMenu, - iconWidget: Icon(allSelected ? Icons.deselect_rounded : Icons.select_all_rounded, size: 18), - onTap: () { - OmiHaptics.light(); - _searchFocusNode.unfocus(); - if (allSelected) { - provider.clearSelection(); - } else { - if (!provider.isSelectionMode) provider.startSelection(); - provider.selectAllItems(); - } - }, - ), - if (context.read()?.isOn ?? false) - PullDownMenuItem( - title: _groupByProject ? context.l10n.tasksGroupByDate : context.l10n.tasksGroupByProject, - iconWidget: Icon(_groupByProject ? Icons.event_outlined : Icons.folder_outlined, size: 18), - onTap: () { - OmiHaptics.light(); - final review = context.read(); - if (!_groupByProject && review != null && review.projects.isEmpty) review.loadProjects(); - setState(() => _groupByProject = !_groupByProject); - }, - ), - PullDownMenuItem( - title: showingCompleted ? context.l10n.hideCompletedTasks : context.l10n.showCompletedTasks, - iconWidget: Icon(showingCompleted ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), - onTap: () { - OmiHaptics.light(); - provider.toggleShowCompletedView(); - }, - ), - ], - buttonBuilder: (context, showMenu) => OmiIconButton.filled( - icon: const Icon(Icons.more_horiz_rounded), - label: context.l10n.moreOptions, - color: OmiColors.textSecondary, - diameter: 40, - onPressed: () { - OmiHaptics.selection(); - showMenu(); - }, - ), - ); - } - Widget _buildNoSearchResultsContent() { return OmiEmptyState(icon: Icons.search_off_rounded, title: context.l10n.noResultsFound); } @@ -467,6 +406,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli excludeFromSemantics: true, onTap: () {}, child: RefreshIndicator( + notificationPredicate: OmiRefreshControl.materialPredicate, onRefresh: () async { OmiHaptics.medium(); return provider.forceRefreshActionItems(); @@ -478,6 +418,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (OmiRefreshControl.supported) OmiRefreshControl(onRefresh: _pullRefresh), SliverFillRemaining( hasScrollBody: false, child: Center(child: ActionItemsApiStatus(provider: provider)), @@ -501,6 +442,9 @@ class _ActionItemsPageState extends State with AutomaticKeepAli ); } + /// The iOS pull-to-refresh ([OmiRefreshControl]); Android keeps the Material indicator. + Future _pullRefresh() => context.read().forceRefreshActionItems(); + Widget _buildLoadingState() { return CustomScrollView( controller: _scrollController, @@ -517,7 +461,10 @@ class _ActionItemsPageState extends State with AutomaticKeepAli return CustomScrollView( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), - slivers: [SliverFillRemaining(hasScrollBody: false, child: Center(child: _buildEmptyTasksContent()))], + slivers: [ + if (OmiRefreshControl.supported) OmiRefreshControl(onRefresh: _pullRefresh), + SliverFillRemaining(hasScrollBody: false, child: Center(child: _buildEmptyTasksContent())), + ], ); } @@ -527,7 +474,6 @@ class _ActionItemsPageState extends State with AutomaticKeepAli child: KeyedSubtree( key: const ValueKey('omi.action_items.empty'), child: OmiEmptyState( - icon: Icons.task_alt_rounded, title: context.l10n.noTasksYet, titleLayoutReference: context.l10n.noConversationsYet, message: context.l10n.tasksEmptyStateMessage, @@ -547,6 +493,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (OmiRefreshControl.supported) OmiRefreshControl(onRefresh: _pullRefresh), const SliverPadding(padding: EdgeInsets.only(top: 8)), SliverToBoxAdapter(child: _buildPageHeader(provider)), @@ -568,7 +515,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli ); }, childCount: filteredItems.length), ), - ] else if (_groupByProject) ...[ + ] else if (provider.groupByProject) ...[ const SliverPadding(padding: EdgeInsets.only(top: 6)), ...projectTaskSections( context: context, @@ -1047,46 +994,85 @@ class _ActionItemsPageState extends State with AutomaticKeepAli // One meaning on every row, at every indent level: swipe right completes (or reopens), swipe // left deletes with Undo. Indenting lives in the long-press menu and in drag. - return Dismissible( - key: Key('dismiss_${item.id}'), - direction: DismissDirection.horizontal, - dismissThresholds: const {DismissDirection.startToEnd: 0.3, DismissDirection.endToStart: 0.3}, - confirmDismiss: (direction) async { - if (direction == DismissDirection.startToEnd) { - await _toggleCompleted(provider, item); - return false; - } - return true; - }, - background: Container( - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 20.0), - decoration: BoxDecoration( - color: item.completed ? OmiColors.surface3 : OmiColors.success, - borderRadius: OmiRadius.smAll, - ), - child: Icon(item.completed ? Icons.undo : Icons.check, color: OmiColors.textPrimary), - ), - secondaryBackground: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20.0), - decoration: BoxDecoration(color: OmiColors.danger, borderRadius: OmiRadius.smAll), - child: Icon(Icons.delete_outline, color: OmiColors.textPrimary), - ), - onDismissed: (direction) { - if (direction == DismissDirection.endToStart) { - _deleteTask(item); - } - }, - child: draggable, - ); + return _foldable( + item, + Dismissible( + key: Key('dismiss_${item.id}'), + direction: DismissDirection.horizontal, + dismissThresholds: const {DismissDirection.startToEnd: 0.3, DismissDirection.endToStart: 0.3}, + confirmDismiss: (direction) async { + if (direction == DismissDirection.startToEnd) { + await _toggleCompleted(provider, item); + return false; + } + return true; + }, + background: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 20.0), + decoration: BoxDecoration( + color: item.completed ? OmiColors.surface3 : OmiColors.success, + borderRadius: OmiRadius.smAll, + ), + child: Icon(item.completed ? Icons.undo : Icons.check, color: OmiColors.textPrimary), + ), + secondaryBackground: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20.0), + decoration: BoxDecoration(color: OmiColors.danger, borderRadius: OmiRadius.smAll), + child: Icon(Icons.delete_outline, color: OmiColors.textPrimary), + ), + onDismissed: (direction) { + if (direction == DismissDirection.endToStart) { + _deleteTask(item); + } + }, + child: draggable, + )); } Future _toggleCompleted(ActionItemsProvider provider, ActionItemWithMetadata item) async { OmiHaptics.light(); - final saved = await provider.updateActionItemState(item, !item.completed); + final completing = !item.completed; + // The open view drops completed tasks and the completed view drops reopened ones: either way + // the row is about to leave, so it shows its new state and folds away first. + final leaves = completing != provider.showCompletedView; + final animate = leaves && OmiMotion.of(context).emphasized != Duration.zero; + if (animate) { + if (_completing.containsKey(item.id)) return; + setState(() => _completing[item.id] = completing); + await Future.delayed(_showDone); + if (!mounted) return; + setState(() => _folding.add(item.id)); + await Future.delayed(_fold); + if (!mounted) return; + } + final saved = await provider.updateActionItemState(item, completing); if (!saved && mounted) OmiFeedback.error(context, context.l10n.failedToUpdateActionItem); - if (saved && !item.completed) _onActionItemCompleted(); + if (mounted && animate) { + setState(() { + _completing.remove(item.id); + _folding.remove(item.id); + }); + } + if (saved && completing) _onActionItemCompleted(); + } + + /// Folds a just-completed row closed (height and opacity) before it leaves the list. + Widget _foldable(ActionItemWithMetadata item, Widget row) { + final folding = _folding.contains(item.id); + return AnimatedSize( + // Per task, so the next row never inherits a folded slot and grows back in. + key: ValueKey('fold_${item.id}'), + duration: _fold, + curve: Curves.easeInOutCubic, + alignment: Alignment.topCenter, + child: AnimatedOpacity( + duration: _fold, + opacity: folding ? 0 : 1, + child: folding ? const SizedBox(width: double.infinity, height: 0) : row, + ), + ); } /// Long-press menu: the same shape as memories and conversations, with Select for multi-select @@ -1148,6 +1134,8 @@ class _ActionItemsPageState extends State with AutomaticKeepAli final indentLevel = _getIndentLevel(item); final goalTitle = _getGoalTitleForTask(item); final isSelected = provider.isSelectionMode && provider.isItemSelected(item.id); + // Ticked and about to fold away: already looks done. + final done = _completing[item.id] ?? item.completed; return GestureDetector( behavior: HitTestBehavior.opaque, @@ -1193,7 +1181,17 @@ class _ActionItemsPageState extends State with AutomaticKeepAli child: SizedBox( width: 44, height: 44, - child: Center(child: TaskCompletionMark(completed: item.completed)), + child: Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + transitionBuilder: (child, a) => ScaleTransition( + scale: Tween(begin: 0.6, end: 1.0) + .animate(CurvedAnimation(parent: a, curve: Curves.easeOutBack)), + child: FadeTransition(opacity: a, child: child), + ), + child: TaskCompletionMark(key: ValueKey(done), completed: done), + ), + ), ), ), ), @@ -1208,10 +1206,10 @@ class _ActionItemsPageState extends State with AutomaticKeepAli Text( item.description, style: OmiType.callout.copyWith( - color: item.completed ? OmiColors.textTertiary : OmiColors.textPrimary, + color: done ? OmiColors.textTertiary : OmiColors.textPrimary, fontWeight: FontWeight.w500, letterSpacing: -0.2, - decoration: item.completed ? TextDecoration.lineThrough : null, + decoration: done ? TextDecoration.lineThrough : null, decorationColor: OmiColors.textTertiary, ), ), diff --git a/app/lib/pages/action_items/widgets/tasks_more_menu.dart b/app/lib/pages/action_items/widgets/tasks_more_menu.dart new file mode 100644 index 00000000000..0d26618ea2d --- /dev/null +++ b/app/lib/pages/action_items/widgets/tasks_more_menu.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; +import 'package:pull_down_button/pull_down_button.dart'; + +import 'package:omi/pages/settings/task_integrations_page.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/providers/review_provider.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; + +/// Tasks' one page action: a bare "⋯" beside the Home / Tasks switcher. It holds selection, +/// grouping, the completed view and export, so the shell header stays the same on both pages. +class TasksMoreMenu extends StatelessWidget { + const TasksMoreMenu({super.key}); + + static const buttonKey = ValueKey('tasks_more_button'); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final l10n = context.l10n; + final showingCompleted = provider.showCompletedView; + final groupByProject = provider.groupByProject; + final hasItems = provider.actionItems.isNotEmpty; + final allSelected = hasItems && provider.selectedCount == provider.actionItems.length; + + return PullDownButton( + itemBuilder: (context) => [ + if (hasItems) ...[ + PullDownMenuItem( + title: l10n.selectActionItems, + iconWidget: const Icon(Icons.check_box_outlined, size: 18), + onTap: () { + OmiHaptics.light(); + primaryFocus?.unfocus(); + provider.startSelection(); + }, + ), + PullDownMenuItem( + title: allSelected ? l10n.deselectAllTasksMenu : l10n.selectAllTasksMenu, + iconWidget: Icon(allSelected ? Icons.deselect_rounded : Icons.select_all_rounded, size: 18), + onTap: () { + OmiHaptics.light(); + primaryFocus?.unfocus(); + if (allSelected) { + provider.clearSelection(); + } else { + if (!provider.isSelectionMode) provider.startSelection(); + provider.selectAllItems(); + } + }, + ), + ], + if (context.read()?.isOn ?? false) + PullDownMenuItem( + title: groupByProject ? l10n.tasksGroupByDate : l10n.tasksGroupByProject, + iconWidget: Icon(groupByProject ? Icons.event_outlined : Icons.folder_outlined, size: 18), + onTap: () { + OmiHaptics.light(); + final review = context.read(); + if (!groupByProject && review != null && review.projects.isEmpty) review.loadProjects(); + provider.toggleGroupByProject(); + }, + ), + PullDownMenuItem( + title: showingCompleted ? l10n.hideCompletedTasks : l10n.showCompletedTasks, + iconWidget: Icon(showingCompleted ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 18), + onTap: () { + OmiHaptics.light(); + provider.toggleShowCompletedView(); + }, + ), + PullDownMenuItem( + title: l10n.exportButton, + iconWidget: const Icon(Icons.ios_share_rounded, size: 18), + onTap: () { + PlatformManager.instance.analytics.exportTasksBannerClicked(); + routeToPage(context, const TaskIntegrationsPage()); + }, + ), + ], + buttonBuilder: (context, showMenu) => OmiIconButton( + key: buttonKey, + icon: const Icon(Icons.more_horiz_rounded), + label: l10n.moreOptions, + color: OmiColors.textSecondary, + onPressed: () { + OmiHaptics.selection(); + showMenu(); + }, + ), + ); + } +} diff --git a/app/lib/pages/capture/widgets/widgets.dart b/app/lib/pages/capture/widgets/widgets.dart index 7a9828f9076..956d529e8b3 100644 --- a/app/lib/pages/capture/widgets/widgets.dart +++ b/app/lib/pages/capture/widgets/widgets.dart @@ -21,11 +21,29 @@ import 'package:omi/widgets/conversation_photo_image.dart'; import 'package:omi/widgets/photos_grid.dart'; import 'package:omi/widgets/transcript.dart'; -class SpeechProfileCardWidget extends StatelessWidget { +/// "Teach Omi your voice" on Home, for readers who have no voice profile yet (onboarding no longer +/// records one). Closing it hides it for good and points to Settings > Voice Profile. +class SpeechProfileCardWidget extends StatefulWidget { const SpeechProfileCardWidget({super.key}); + @override + State createState() => _SpeechProfileCardWidgetState(); +} + +class _SpeechProfileCardWidgetState extends State { + bool _dismissed = SharedPreferencesUtil().voiceProfileCardDismissed; + + void _dismiss() { + OmiHaptics.light(); + PlatformManager.instance.analytics.track('Voice Profile Card Dismissed'); + SharedPreferencesUtil().voiceProfileCardDismissed = true; + setState(() => _dismissed = true); + OmiFeedback.info(context, context.l10n.voiceProfileCardDismissed); + } + @override Widget build(BuildContext context) { + if (_dismissed) return const SizedBox(); return Consumer( builder: (context, provider, child) { if (provider.isLoading) return const SizedBox(); @@ -43,6 +61,7 @@ class SpeechProfileCardWidget extends StatelessWidget { label: context.l10n.teachOmiYourVoice, // A dot marks a setup step still to do. badge: true, + onDismiss: _dismiss, onTap: () async { PlatformManager.instance.analytics.pageOpened('Speech Profile Memories'); bool hasSpeakerProfile = SharedPreferencesUtil().hasSpeakerProfile; @@ -65,44 +84,63 @@ class SpeechProfileCardWidget extends StatelessWidget { /// A tappable card row on the capture surfaces: icon, label, chevron; announced as a button. class _CardRow extends StatelessWidget { - const _CardRow({required this.icon, required this.label, required this.onTap, this.badge = false}); + const _CardRow({required this.icon, required this.label, required this.onTap, this.badge = false, this.onDismiss}); final IconData icon; final String label; final VoidCallback onTap; final bool badge; + /// Shows a close button in place of the chevron. + final VoidCallback? onDismiss; + @override Widget build(BuildContext context) { + final open = Semantics( + button: true, + label: label, + excludeSemantics: true, + child: InkWell( + onTap: onTap, + child: Padding( + padding: + EdgeInsets.fromLTRB(OmiSpacing.md, OmiSpacing.md, onDismiss == null ? OmiSpacing.md : 0, OmiSpacing.md), + child: Row( + children: [ + Icon(icon, color: OmiColors.textPrimary), + const SizedBox(width: OmiSpacing.md), + Expanded(child: Text(label, style: OmiType.callout)), + if (badge) ...[ + Icon(Icons.fiber_manual_record, color: OmiColors.danger, size: 10), + const SizedBox(width: OmiSpacing.xs), + ], + if (onDismiss == null) Icon(Icons.arrow_forward_ios, color: OmiColors.textPrimary, size: 16), + ], + ), + ), + ), + ); return Padding( padding: const EdgeInsets.fromLTRB(OmiSpacing.md, OmiSpacing.md, OmiSpacing.md, 0), - child: Semantics( - button: true, - label: label, - excludeSemantics: true, - child: Material( - color: OmiColors.surface1, - borderRadius: OmiRadius.xlAll, - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(OmiSpacing.md), - child: Row( + child: Material( + color: OmiColors.surface1, + borderRadius: OmiRadius.xlAll, + clipBehavior: Clip.antiAlias, + child: onDismiss == null + ? open + : Row( children: [ - Icon(icon, color: OmiColors.textPrimary), - const SizedBox(width: OmiSpacing.md), - Expanded(child: Text(label, style: OmiType.callout)), - if (badge) ...[ - Icon(Icons.fiber_manual_record, color: OmiColors.danger, size: 10), - const SizedBox(width: OmiSpacing.xs), - ], - Icon(Icons.arrow_forward_ios, color: OmiColors.textPrimary, size: 16), + Expanded(child: open), + OmiIconButton( + key: const ValueKey('voice_profile_card_dismiss'), + icon: const Icon(Icons.close_rounded, size: 18), + label: context.l10n.dismiss, + color: OmiColors.textSecondary, + onPressed: onDismiss!, + ), + const SizedBox(width: OmiSpacing.xxs), ], ), - ), - ), - ), ), ); } diff --git a/app/lib/pages/chat/page.dart b/app/lib/pages/chat/page.dart index f4c1b8fe484..9555231a72d 100644 --- a/app/lib/pages/chat/page.dart +++ b/app/lib/pages/chat/page.dart @@ -21,8 +21,6 @@ import 'package:omi/pages/apps/widgets/capability_apps_page.dart'; import 'package:omi/pages/chat/chat_scroll_policy.dart'; import 'package:omi/pages/chat/widgets/ai_message.dart'; import 'package:omi/pages/chat/widgets/jump_to_latest_button.dart'; -import 'package:omi/pages/paywall/paywall_router.dart'; -import 'package:omi/pages/settings/widgets/plans_sheet.dart'; import 'package:omi/pages/chat/widgets/user_message.dart'; import 'package:omi/pages/chat/widgets/voice_recorder_widget.dart'; import 'package:omi/pages/settings/integrations_page.dart'; @@ -45,6 +43,7 @@ import 'package:omi/pages/chat/widgets/chat_chrome.dart'; import 'package:omi/pages/chat/widgets/chat_entrance.dart'; import 'package:omi/pages/chat/widgets/chat_followup_chip.dart'; import 'package:omi/ui/ui.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; class ChatPage extends StatefulWidget { final bool isPivotBottom; @@ -306,8 +305,13 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, : provider.isClearingChat ? OmiLoadingState(label: context.l10n.deletingMessages) : (provider.messages.isEmpty) - ? ChatGreeting(isConnected: connectivityProvider.isConnected, name: prefs.givenName) - : _buildTranscript(provider), + ? ChatGreeting( + isConnected: connectivityProvider.isConnected, + name: prefs.givenName, + welcome: ChatWelcome.pending, + animateWelcome: !ChatWelcome.shown, + ) + : _transcriptMarkingWelcomed(provider), ), _buildComposer(context, provider, connectivityProvider), ], @@ -693,7 +697,15 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, ); } + /// Anyone who already has a chat history is past the welcome, so clearing it later doesn't bring + /// the welcome back. + Widget _transcriptMarkingWelcomed(MessageProvider provider) { + if (ChatWelcome.pending) ChatWelcome.markDone(); + return _buildTranscript(provider); + } + _sendMessageUtil(String text) async { + ChatWelcome.markDone(); var provider = context.read(); // Guard against re-entry (rapid double-tap of send, voice→transcribeSuccess // race firing onTranscriptReady twice, etc.). Without this the chat could @@ -740,23 +752,9 @@ class ChatPageState extends State with AutomaticKeepAliveClientMixin, Future _showPlansSheetOnQuotaExceeded() async { if (!mounted) return; - final usage = context.read(); // Refresh subscription data so the plans sheet is up-to-date - usage.fetchSubscription(); - if (await PaywallRouter.openVariant(context, source: 'chat_quota_exceeded')) return; - if (!mounted) return; - PlatformManager.instance.analytics.paywallOpened( - 'chat_quota_exceeded', - currentPlan: usage.subscription?.subscription.plan.wireName ?? 'unknown', - ); - final title = await plansSheetTitle(context); - if (!mounted) return; - showOmiSheet( - context: context, - title: title, - padding: EdgeInsets.zero, - builder: (_) => const _PlansSheetWrapper(analyticsSource: 'chat_quota_exceeded'), - ); + context.read().fetchSubscription(); + openUpgradePaywall(context, source: 'chat_quota_exceeded'); } sendInitialAppMessage(App? app) async { @@ -1153,53 +1151,6 @@ class _SelectedTextChip extends StatelessWidget { } } -class _PlansSheetWrapper extends StatefulWidget { - const _PlansSheetWrapper({this.analyticsSource = 'chat_quota_exceeded'}); - - final String analyticsSource; - - @override - State<_PlansSheetWrapper> createState() => _PlansSheetWrapperState(); -} - -class _PlansSheetWrapperState extends State<_PlansSheetWrapper> with TickerProviderStateMixin { - late AnimationController _waveController; - late AnimationController _arrowController; - late AnimationController _notesController; - late Animation _arrowAnimation; - - @override - void initState() { - super.initState(); - _waveController = AnimationController(vsync: this, duration: const Duration(seconds: 2))..repeat(); - _arrowController = AnimationController(vsync: this, duration: const Duration(milliseconds: 800))..repeat(); - _notesController = AnimationController(vsync: this, duration: const Duration(seconds: 3))..repeat(); - _arrowAnimation = Tween( - begin: 0, - end: 10, - ).animate(CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut)); - } - - @override - void dispose() { - _waveController.dispose(); - _arrowController.dispose(); - _notesController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PlansSheet( - waveController: _waveController, - notesController: _notesController, - arrowController: _arrowController, - arrowAnimation: _arrowAnimation, - analyticsSource: widget.analyticsSource, - ); - } -} - /// Row identity for the transcript sliver. Persisted messages use their id. /// Placeholder replies all keep id `0000`, so those rows use the message object. Key _chatMessageRowKey(ServerMessage message) => message.isEmpty ? ObjectKey(message) : ValueKey(message.id); diff --git a/app/lib/pages/chat/widgets/chat_starters.dart b/app/lib/pages/chat/widgets/chat_starters.dart index 2d217a9d1a7..58852472c02 100644 --- a/app/lib/pages/chat/widgets/chat_starters.dart +++ b/app/lib/pages/chat/widgets/chat_starters.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/pages/chat/widgets/chat_entrance.dart'; +import 'package:omi/pages/chat/widgets/typing_indicator.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -15,13 +19,45 @@ int countConversationsForLocalDay(Iterable conversations, Da }).length; } +/// Omi's first chat message: shown in the empty chat until the person sends their first message, +/// typed in the first time it appears. Remembered per account. +abstract final class ChatWelcome { + static String _key(String uid) => 'chat/welcome/$uid'; + + /// Still to be answered: the person has not sent a chat message since it was introduced. + static bool get pending => SharedPreferencesUtil().getString(_key(SharedPreferencesUtil().uid)) != 'done'; + + /// It has been typed in once; later visits show it settled. + static bool get shown => SharedPreferencesUtil().getString(_key(SharedPreferencesUtil().uid)) != ''; + + static void markShown() { + if (!shown) SharedPreferencesUtil().saveString(_key(SharedPreferencesUtil().uid), 'shown'); + } + + static void markDone() => SharedPreferencesUtil().saveString(_key(SharedPreferencesUtil().uid), 'done'); +} + class ChatGreeting extends StatelessWidget { - const ChatGreeting({super.key, required this.isConnected, this.name = '', this.hour, this.todayCount}); + const ChatGreeting({ + super.key, + required this.isConnected, + this.name = '', + this.hour, + this.todayCount, + this.welcome = false, + this.animateWelcome = true, + }); final bool isConnected; final String name; final int? hour; final int? todayCount; + /// Show Omi's welcome message in place of "What do you want to know?". + final bool welcome; + + /// Type the welcome in (the first time); otherwise it appears settled. + final bool animateWelcome; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -58,19 +94,22 @@ class ChatGreeting extends StatelessWidget { child: _ConversationCount(count: count, style: heading), ), const SizedBox(height: 10), - ChatRise( - key: const Key('chat_question_rise'), - interval: ChatIntro.question, - child: Text( - l10n.whatDoYouWantToKnow, - style: OmiType.title3.copyWith( - fontWeight: FontWeight.w500, - letterSpacing: -0.2, - height: 1.2, - color: OmiColors.textSecondary, + if (welcome) + _WelcomeMessage(text: l10n.chatWelcomeMessage, animate: animateWelcome) + else + ChatRise( + key: const Key('chat_question_rise'), + interval: ChatIntro.question, + child: Text( + l10n.whatDoYouWantToKnow, + style: OmiType.title3.copyWith( + fontWeight: FontWeight.w500, + letterSpacing: -0.2, + height: 1.2, + color: OmiColors.textSecondary, + ), ), ), - ), ], ), ), @@ -157,3 +196,91 @@ class ChatSuggestions extends StatelessWidget { ); } } + +/// Omi "texting" its first message: the typing dots, then the words arriving like a streamed reply, +/// in the same plain style as Omi's chat answers. +class _WelcomeMessage extends StatefulWidget { + const _WelcomeMessage({required this.text, required this.animate}); + + final String text; + final bool animate; + + @override + State<_WelcomeMessage> createState() => _WelcomeMessageState(); +} + +class _WelcomeMessageState extends State<_WelcomeMessage> { + late final List _words = widget.text.split(' '); + late int _shown = widget.animate ? 0 : _words.length; + bool _typing = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + if (!widget.animate) return; + _typing = true; + _timer = Timer(const Duration(milliseconds: 1300), () { + if (!mounted) return; + setState(() => _typing = false); + // omi-perf-allow: fast-periodic-timer -- one-shot reveal of the first chat message (~2 s), setState local to this small widget + _timer = Timer.periodic(const Duration(milliseconds: 45), (timer) { + if (!mounted) return timer.cancel(); + setState(() => _shown++); + if (_shown >= _words.length) { + timer.cancel(); + ChatWelcome.markShown(); + } + }); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final shown = reduceMotion ? _words.length : _shown; + if (_typing && !reduceMotion) { + return const Padding( + key: Key('chat_welcome_typing'), + padding: EdgeInsets.symmetric(vertical: OmiSpacing.sm), + child: Row(mainAxisSize: MainAxisSize.min, children: [TypingIndicator()]), + ); + } + return Semantics( + liveRegion: true, + label: widget.text, + excludeSemantics: true, + child: Text( + // The whole message lays out from the start, so lines don't jump as words arrive. + widget.text, + key: const Key('chat_welcome_message'), + style: OmiType.body.copyWith(height: 1.45, color: OmiColors.textPrimary), + ).revealedUpTo(shown, _words), + ); + } +} + +extension on Text { + /// The same text, with the words after [count] drawn transparent so the layout never shifts. + Widget revealedUpTo(int count, List words) { + if (count >= words.length) return this; + final visible = words.take(count).join(' '); + final hidden = words.skip(count).join(' '); + return Text.rich( + key: key, + TextSpan( + style: style, + children: [ + TextSpan(text: visible), + TextSpan(text: count == 0 ? hidden : ' $hidden', style: const TextStyle(color: Colors.transparent)), + ], + ), + ); + } +} diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index 08663a9a473..6f008f7282b 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -522,16 +522,21 @@ class _ConversationsPageState extends State with AutomaticKee processingByDate: processingByDate, ); + Future refresh() async { + _lastLoadMoreRequestKey = null; + Provider.of(context, listen: false).refreshInProgressConversations(); + await Future.wait([ + convoProvider.getInitialConversations(), + Provider.of(context, listen: false).refresh(), + if (_recapsKey.currentState != null) _recapsKey.currentState!.refresh(), + ]); + } + return RefreshIndicator( + notificationPredicate: OmiRefreshControl.materialPredicate, onRefresh: () async { HapticFeedback.mediumImpact(); - _lastLoadMoreRequestKey = null; - Provider.of(context, listen: false).refreshInProgressConversations(); - await Future.wait([ - convoProvider.getInitialConversations(), - Provider.of(context, listen: false).refresh(), - if (_recapsKey.currentState != null) _recapsKey.currentState!.refresh(), - ]); + await refresh(); }, color: OmiColors.onAccent, backgroundColor: OmiColors.accent, @@ -539,8 +544,10 @@ class _ConversationsPageState extends State with AutomaticKee controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (OmiRefreshControl.supported) OmiRefreshControl(onRefresh: refresh), // The live capture row: recording, a call, or a conversation being captured. - const SliverToBoxAdapter(child: ConversationCaptureWidget(showsCall: true)), + const SliverToBoxAdapter( + child: ConversationCaptureWidget(showsCall: true, deviceStatusInBottomBar: true)), const SliverToBoxAdapter(child: SpeechProfileCardWidget()), const SliverToBoxAdapter(child: UpdateFirmwareCardWidget()), // Review absorbs the voice prompts; the old card shows only while Review is off. diff --git a/app/lib/pages/conversations/live_capture_summary.dart b/app/lib/pages/conversations/live_capture_summary.dart new file mode 100644 index 00000000000..be67ebca712 --- /dev/null +++ b/app/lib/pages/conversations/live_capture_summary.dart @@ -0,0 +1,168 @@ +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/conversations/capture_state_labels.dart'; +import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/utils/enums.dart'; + +/// How a live capture reads at a glance: capturing fine, paused or waiting, or something the +/// person should look at. +enum CaptureTone { live, quiet, attention } + +/// What is recording now and how it is going, in the words the Home card and the Home pill both +/// show. One source for both, so the two never disagree. +class LiveCaptureSummary { + const LiveCaptureSummary({ + required this.source, + required this.copy, + required this.tone, + this.batch = false, + this.readerPaused = false, + this.micTaken = false, + this.unverified = false, + }); + + /// `phone`, or the capturing device's source key (`omi`, `limitless`, ...). + final String source; + final CaptureCardCopy copy; + final CaptureTone tone; + + /// Transcribe Later: audio saved on the phone, no live transcript. + final bool batch; + + /// The person paused it (Resume brings it back). + final bool readerPaused; + + /// The OS or another app holds the microphone. + final bool micTaken; + + /// A pendant link that is up but not yet proven to be capturing. + final bool unverified; + + bool get isPhone => source == 'phone'; + + /// Healthy and live: nothing to explain, the live transcript is the next thing to see. + bool get healthy => tone == CaptureTone.live && !batch && copy.explanation == null; +} + +/// Summarizes the capture in [provider], or null when nothing is recording. A pendant that dropped +/// mid-capture and an Omi call are not covered: their surfaces track those themselves. +LiveCaptureSummary? summarizeLiveCapture(AppLocalizations l10n, CaptureProvider provider) { + final liveSource = provider.liveCaptureSource; + final isDeviceRecording = liveSource != null && liveSource != 'phone'; + // Keep automatic repair and legitimate pendant sleep quiet: a restored link alone is not Listening. + final unverified = isDeviceRecording && !provider.isPaused && !provider.pendantCaptureVerified; + final prefs = SharedPreferencesUtil(); + + if ((isDeviceRecording && prefs.batchModeEnabled) || + provider.isPhoneMicBatchRecording || + (provider.havingRecordingDevice && prefs.batchModeEnabled)) { + return _summarizeBatch(l10n, provider, unverified: unverified); + } + + // A phone-mic batch session reports RecordingState.record too; it is handled above. + final isPhoneRecording = provider.recordingState == RecordingState.record || + provider.recordingState == RecordingState.systemAudioRecord || + provider.recordingState == RecordingState.initialising || + provider.recordingState == RecordingState.interrupted || + provider.isPhoneMicPaused; + if (!isDeviceRecording && !isPhoneRecording) return null; + + // A pause the reader chose (the control reads Resume only then). + final isAudioInterrupted = provider.recordingState == RecordingState.interrupted; + var isPaused = false; + if (isDeviceRecording) { + isPaused = provider.isPaused && provider.recordingState == RecordingState.pause; + } else { + isPaused = provider.isPhoneMicPaused || provider.isPaused; + } + // `interrupted` is the OS holding the mic (explained, no control) or capture recovering on its own + // (Reconnecting, Pause still works); a reader pause keeps Resume either way. + final interruption = captureInterruption( + interrupted: !isDeviceRecording && isAudioInterrupted, + readerPaused: isPaused, + osHoldsMic: provider.isCallActive, + ); + final micTaken = interruption == CaptureInterruption.micTaken; + final hasTerminalTranscriptionFailure = + provider.terminalTranscriptionFailure != null || provider.localSttFailure != null; + // WAL saves audio locally whatever the transcription connection does, so a degraded transcription + // names the consequence ("Audio saved, transcribes later"), not a stop. + final displayState = liveCaptureDisplayState( + audioInterrupted: micTaken, + paused: isPaused, + transcriptionUnavailable: hasTerminalTranscriptionFailure, + bufferingFor: provider.customSttBufferingDuration, + reconnecting: interruption == CaptureInterruption.recovering, + capturingPhotos: provider.photos.isNotEmpty, + ); + // Initialising: the microphone is opening and no audio flows yet, so it is not Listening. + final starting = !isDeviceRecording && + provider.recordingState == RecordingState.initialising && + displayState == CaptureDisplayState.listening; + final copy = provider.localSttFailure != null + ? CaptureCardCopy(l10n.transcriptionUnavailable, + detail: l10n.setupOnDevice, explanation: l10n.transcriptionUnavailableRecordingSaved) + : unverified + ? CaptureCardCopy(l10n.captureSourcePendant) + : starting + ? CaptureCardCopy(l10n.captureStarting) + : captureCardCopy(l10n, displayState, micTaken: micTaken, socketDown: !provider.transcriptServiceReady); + + final CaptureTone tone; + if (copy.explanation != null) { + tone = CaptureTone.attention; + } else if (isPaused || unverified || displayState == CaptureDisplayState.reconnecting) { + tone = CaptureTone.quiet; + } else { + tone = CaptureTone.live; + } + return LiveCaptureSummary( + source: isDeviceRecording ? liveSource : 'phone', + copy: copy, + tone: tone, + readerPaused: isPaused, + micTaken: micTaken, + unverified: unverified, + ); +} + +LiveCaptureSummary _summarizeBatch(AppLocalizations l10n, CaptureProvider provider, {required bool unverified}) { + final isLimitless = provider.recordingDevice?.type == DeviceType.limitless; + final prefs = SharedPreferencesUtil(); + final muted = !isLimitless && provider.offlineMuted; + // Native low-storage flag: capture is paused, so it must not look healthy. + final storageFull = !isLimitless && prefs.getBool('batchStorageFull'); + final source = + provider.isPhoneMicBatchRecording ? 'phone' : (provider.liveCaptureSource ?? (isLimitless ? 'limitless' : 'omi')); + + final CaptureCardCopy copy; + if (isLimitless) { + final minutesStored = (prefs.pendantPagesStored * 1.4 / 60).round(); + final almostFull = prefs.pendantStorageAlmostFull; + final detail = [ + if (minutesStored > 0) l10n.pendantMinutesStored(minutesStored), + if (almostFull) l10n.captureStorageAlmostFull, + ].join(' · '); + copy = CaptureCardCopy(l10n.recording, + detail: detail.isEmpty ? null : detail, explanation: almostFull ? l10n.pendantStorageAlmostFull : null); + } else if (storageFull) { + copy = CaptureCardCopy(l10n.paused, detail: l10n.capturePhoneStorageFull, explanation: l10n.transcribeLaterStorageFull); + } else if (muted) { + copy = CaptureCardCopy(l10n.paused); + } else if (unverified) { + copy = CaptureCardCopy(l10n.captureSourcePendant); + } else { + copy = CaptureCardCopy(l10n.recording, detail: l10n.captureAudioSavedTranscribesLater); + } + return LiveCaptureSummary( + source: source, + copy: copy, + tone: copy.explanation != null + ? CaptureTone.attention + : (muted || unverified ? CaptureTone.quiet : CaptureTone.live), + batch: true, + readerPaused: muted, + unverified: unverified, + ); +} diff --git a/app/lib/pages/conversations/widgets/conversation_list_item.dart b/app/lib/pages/conversations/widgets/conversation_list_item.dart index 755b8ce3568..d67a391e0bc 100644 --- a/app/lib/pages/conversations/widgets/conversation_list_item.dart +++ b/app/lib/pages/conversations/widgets/conversation_list_item.dart @@ -15,7 +15,7 @@ import 'package:omi/pages/conversation_detail/page.dart'; import 'package:omi/widgets/conversation_bottom_bar.dart' show ConversationTab; import 'package:omi/pages/conversations/conversation_action_analytics.dart'; import 'package:omi/pages/conversations/conversation_actions.dart'; -import 'package:omi/pages/settings/usage_page.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; @@ -176,7 +176,10 @@ class _ConversationListItemState extends State { Future _open(BuildContext context, ConversationProvider provider) async { if (widget.conversation.isLocked) { if (!context.read().showSubscriptionUI) return; - routeToPage(context, const UsagePage()); + await openUpgradePaywall( + context, + source: 'locked_conversation', + ); return; } HapticFeedback.selectionClick(); @@ -427,7 +430,7 @@ class _ConversationListItemState extends State { if (!widget.conversation.isLocked || widget.inLockedRun) return content; return OmiLockedPreview( status: context.l10n.statusPending, - label: context.l10n.viewUsage, + label: context.l10n.upgrade, onPressed: onTap, child: content, ); @@ -855,7 +858,10 @@ class LockedConversationRun extends StatelessWidget { return; } if (!context.read().showSubscriptionUI) return; - routeToPage(context, const UsagePage()); + await openUpgradePaywall( + context, + source: 'locked_conversation', + ); } @override @@ -867,7 +873,7 @@ class LockedConversationRun extends StatelessWidget { // Each row keeps its own long-press menu, swipe-to-delete and selection handling. interactive: true, status: context.l10n.statusPending, - label: context.l10n.viewUsage, + label: context.l10n.upgrade, onPressed: () => _openUsage(context), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/app/lib/pages/conversations/widgets/empty_conversations.dart b/app/lib/pages/conversations/widgets/empty_conversations.dart index eeaf6d90fee..cf4d7be4180 100644 --- a/app/lib/pages/conversations/widgets/empty_conversations.dart +++ b/app/lib/pages/conversations/widgets/empty_conversations.dart @@ -17,7 +17,6 @@ class NoConversationsHero extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 120), child: OmiEmptyState( - icon: Icons.forum_rounded, title: context.l10n.noConversationsYet, titleLayoutReference: context.l10n.noTasksYet, message: message, @@ -47,7 +46,7 @@ class EmptyConversationsWidget extends StatelessWidget { title: l10n.noStarredConversations, message: l10n.starConversationHint, ) - : OmiEmptyState(icon: Icons.forum_rounded, title: l10n.noConversationsYet), + : OmiEmptyState(title: l10n.noConversationsYet), ); } } diff --git a/app/lib/pages/conversations/widgets/processing_capture.dart b/app/lib/pages/conversations/widgets/processing_capture.dart index b886cdf7ca6..35961e71775 100644 --- a/app/lib/pages/conversations/widgets/processing_capture.dart +++ b/app/lib/pages/conversations/widgets/processing_capture.dart @@ -16,6 +16,7 @@ import 'package:omi/backend/schema/conversation.dart'; import 'package:omi/pages/conversation_capturing/page.dart'; import 'package:omi/pages/capture/widgets/widgets.dart'; import 'package:omi/pages/conversations/capture_state_labels.dart'; +import 'package:omi/pages/conversations/live_capture_summary.dart'; import 'package:omi/pages/processing_conversations/page.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; @@ -27,16 +28,22 @@ import 'package:omi/utils/other/temp.dart'; import 'package:omi/utils/processing_timeout.dart'; import 'package:omi/backend/schema/phone_call.dart'; import 'package:omi/providers/phone_call_provider.dart'; +import 'package:omi/pages/conversations/widgets/conversation_list_item.dart'; import 'package:omi/pages/conversations/widgets/live_capture_card.dart'; import 'package:omi/pages/phone_calls/active_call_page.dart'; import 'package:omi/ui/ui.dart'; class ConversationCaptureWidget extends StatefulWidget { - const ConversationCaptureWidget({super.key, this.showsCall = false}); + const ConversationCaptureWidget({super.key, this.showsCall = false, this.deviceStatusInBottomBar = false}); /// Home shows an Omi call on this card; the Conversations tab has its own call banner. final bool showsCall; + /// Home: the bottom pill is the one place a live recording shows (its status, dot and control; + /// tapping it opens this card in a sheet when there is more to say), so this card shows nothing + /// for a recording here. An Omi call still shows. + final bool deviceStatusInBottomBar; + @override State createState() => _ConversationCaptureWidgetState(); } @@ -118,6 +125,8 @@ class _ConversationCaptureWidgetState extends State { ); } + if (widget.deviceStatusInBottomBar) return const SizedBox.shrink(); + return Consumer( builder: (context, provider, child) { // The card means "what's recording now": hidden when nothing is (a connected pendant @@ -274,51 +283,12 @@ class _ConversationCaptureWidgetState extends State { provider.recordingState == RecordingState.interrupted || provider.isPhoneMicPaused); - // A pause the reader chose (the control reads Resume only then). - final isAudioInterrupted = provider.recordingState == RecordingState.interrupted; - bool isPaused = false; - if (isDeviceRecording) { - isPaused = provider.isPaused && provider.recordingState == RecordingState.pause; - } else if (isPhoneRecording) { - isPaused = provider.isPhoneMicPaused || provider.isPaused; - } - // `interrupted` is the OS holding the mic (explained, no control) or capture recovering on its - // own (Reconnecting, Pause still works); a reader pause keeps Resume either way. - final interruption = captureInterruption( - interrupted: isPhoneRecording && isAudioInterrupted, - readerPaused: isPaused, - osHoldsMic: provider.isCallActive, - ); - final micTaken = interruption == CaptureInterruption.micTaken; - final hasTerminalTranscriptionFailure = - provider.terminalTranscriptionFailure != null || provider.localSttFailure != null; - final bufferingFor = provider.customSttBufferingDuration; - - // Determine if this is an OmiGlass-type device (captures photos) + // Status, pause and microphone state come from the summary the Home pill reads too. + final summary = isDeviceRecording || isPhoneRecording ? summarizeLiveCapture(context.l10n, provider) : null; + final isPaused = summary?.readerPaused ?? false; + final micTaken = summary?.micTaken ?? false; + final copy = summary?.copy ?? CaptureCardCopy(context.l10n.listening); bool hasPhotos = provider.photos.isNotEmpty; - // WAL saves audio locally whatever the transcription connection does, so a degraded - // transcription names the consequence ("Audio saved, transcribes later"), not a stop. - final displayState = liveCaptureDisplayState( - audioInterrupted: micTaken, - paused: isPaused, - transcriptionUnavailable: hasTerminalTranscriptionFailure, - bufferingFor: bufferingFor, - reconnecting: interruption == CaptureInterruption.recovering, - capturingPhotos: hasPhotos, - ); - // Initialising: the microphone is opening and no audio flows yet, so it is not Listening. - final starting = isPhoneRecording && - provider.recordingState == RecordingState.initialising && - displayState == CaptureDisplayState.listening; - final copy = provider.localSttFailure != null - ? CaptureCardCopy(context.l10n.transcriptionUnavailable, - detail: context.l10n.setupOnDevice, explanation: context.l10n.transcriptionUnavailableRecordingSaved) - : unverified - ? CaptureCardCopy(context.l10n.captureSourcePendant) - : starting - ? CaptureCardCopy(context.l10n.captureStarting) - : captureCardCopy(context.l10n, displayState, - micTaken: micTaken, socketDown: !provider.transcriptServiceReady); // When recording is active: the one capture status and control surface. if (isDeviceRecording || isPhoneRecording) { @@ -383,27 +353,10 @@ class _ConversationCaptureWidgetState extends State { : (provider.liveCaptureSource ?? (isLimitless ? 'limitless' : 'omi')); final elapsedSeconds = provider.offlineRecordingElapsedSeconds; - final CaptureCardCopy copy; + final copy = summarizeLiveCapture(l10n, provider)?.copy ?? CaptureCardCopy(l10n.recording); String? note; if (isLimitless) { - final minutesStored = (prefs.pendantPagesStored * 1.4 / 60).round(); - final almostFull = prefs.pendantStorageAlmostFull; - final detail = [ - if (minutesStored > 0) l10n.pendantMinutesStored(minutesStored), - if (almostFull) l10n.captureStorageAlmostFull, - ].join(' · '); - copy = CaptureCardCopy(l10n.recording, - detail: detail.isEmpty ? null : detail, explanation: almostFull ? l10n.pendantStorageAlmostFull : null); note = prefs.pendantDraining ? l10n.pendantSyncingRecordings : l10n.pendantRecordingNote; - } else if (storageFull) { - copy = CaptureCardCopy(l10n.paused, - detail: l10n.capturePhoneStorageFull, explanation: l10n.transcribeLaterStorageFull); - } else if (muted) { - copy = CaptureCardCopy(l10n.paused); - } else if (unverified) { - copy = CaptureCardCopy(l10n.captureSourcePendant); - } else { - copy = CaptureCardCopy(l10n.recording, detail: l10n.captureAudioSavedTranscribesLater); } final actions = <_OfflineAction>[ @@ -681,8 +634,15 @@ class ProcessingConversationWidget extends StatefulWidget { State createState() => _ProcessingConversationWidgetState(); } -class _ProcessingConversationWidgetState extends State { +class _ProcessingConversationWidgetState extends State + with SingleTickerProviderStateMixin { Timer? _timeoutTicker; + + /// About half a minute of motion — a typical summary — then the row rests, so a slow one does not + /// animate for minutes (after the timeout it says so instead). + static const _cycles = 18; + late final AnimationController _motion = + AnimationController(vsync: this, duration: const Duration(milliseconds: 1600))..repeat(count: _cycles); bool _timedOut = false; bool _retrying = false; @@ -726,6 +686,7 @@ class _ProcessingConversationWidgetState extends State 0) OmiDuration.compact(seconds, l10n), + ].join(' • '); + final snippet = conversationSnippet(conversation, maxChars: 140); + final metaStyle = TextStyle(color: OmiColors.textTertiary, fontSize: 14); + // The saved row's layout (glyph, title, time and length), so the finished conversation takes + // its place without the card changing shape: the emoji arrives where the glyph is. return GestureDetector( - onTap: () async { - routeToPage(context, ProcessingConversationPage(conversation: widget.conversation)); - }, + onTap: () => routeToPage(context, ProcessingConversationPage(conversation: conversation)), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + padding: const EdgeInsets.only(top: 8, left: 16, right: 16), child: Container( + key: const ValueKey('processing_conversation_card'), width: double.maxFinite, - decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: BorderRadius.circular(24.0)), - // Static skeleton - no animation to save CPU/battery - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header row - Row( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: OmiColors.surface1, borderRadius: OmiRadius.xlAll), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CondensingGlyph(motion: _motion, still: still), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Icon placeholder - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: OmiColors.surface2, - borderRadius: BorderRadius.circular(12), - ), - ), - const SizedBox(width: 8), - // Processing label - Container( - decoration: BoxDecoration( - color: OmiColors.categorySurface, - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - child: Text( - captureStateLabel(context.l10n, CaptureDisplayState.processing), - style: OmiType.subhead.copyWith(fontWeight: FontWeight.w500), - ), - ), - const Spacer(), - // The real start time, not a placeholder bar (hub audit #25). Text( - OmiDateFormat.of(context).time(widget.conversation.startedAt ?? widget.conversation.createdAt), - style: OmiType.footnote.copyWith(color: OmiColors.textTertiary), + captureStateLabel(l10n, CaptureDisplayState.processing), + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], - ), - const SizedBox(height: 12), - // Title placeholder - Container( - width: double.maxFinite, - height: 16, - decoration: BoxDecoration(color: OmiColors.surface2, borderRadius: BorderRadius.circular(4)), - ), - if (_timedOut) ...[ - const SizedBox(height: 12), - Text( - context.l10n.processingTakingLonger, - style: TextStyle(color: OmiColors.textTertiary, fontSize: 13, height: 1.3), - ), - const SizedBox(height: 10), - Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () {}, // absorb so the card's open-on-tap does not fire - child: TextButton( + const SizedBox(height: 3), + Text(meta, style: metaStyle, maxLines: 1, overflow: TextOverflow.ellipsis), + if (_timedOut) ...[ + const SizedBox(height: OmiSpacing.sm), + Text( + l10n.processingTakingLonger, + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary, height: 1.35), + ), + const SizedBox(height: OmiSpacing.sm), + OmiButton.secondary( key: const Key('processing_conversation_retry_button'), + label: l10n.tryAgain, + icon: Icons.refresh_rounded, + size: OmiButtonSize.compact, + isLoading: _retrying, onPressed: _retrying ? null : _onRetry, - style: TextButton.styleFrom( - foregroundColor: OmiColors.textPrimary, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - minimumSize: const Size(44, 44), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ] else if (snippet.isNotEmpty) ...[ + const SizedBox(height: OmiSpacing.sm), + // What was said, with a light passing over it while Omi reads it. + _ReadingSweep( + motion: _motion, + still: still, + child: Text( + snippet, + style: OmiType.footnote.copyWith(color: OmiColors.textTertiary, height: 1.35), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - child: _retrying ? const OmiSpinner(size: OmiSpinnerSize.small) : Text(context.l10n.tryAgain), ), - ), - ), - ], - ], - ), + ], + ], + ), + ), + ], ), ), ), ); } } + +/// The processing row's glyph, in the emoji's square: three transcript lines draw together into +/// one note and spread back out. Still, it is the three lines. +class _CondensingGlyph extends StatelessWidget { + const _CondensingGlyph({required this.motion, required this.still}); + + final Animation motion; + final bool still; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: OmiColors.surface2, borderRadius: OmiRadius.mdAll), + child: RepaintBoundary( + child: AnimatedBuilder( + animation: motion, + builder: (context, _) => CustomPaint( + painter: _CondensingLinesPainter(t: still ? 0 : motion.value, color: OmiColors.textSecondary), + ), + ), + ), + ); + } +} + +class _CondensingLinesPainter extends CustomPainter { + _CondensingLinesPainter({required this.t, required this.color}); + + final double t; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + // 0→0.5: the lines gather into one short line; 0.5→1: they spread back out. + final phase = t < 0.5 ? Curves.easeInOutCubic.transform(t * 2) : 1 - Curves.easeInOutCubic.transform((t - 0.5) * 2); + final c = size.center(Offset.zero); + final paint = Paint() + ..strokeCap = StrokeCap.round + ..strokeWidth = 2; + const widths = [16.0, 12.0, 8.0]; + for (var k = 0; k < 3; k++) { + final y = c.dy + (k - 1) * 5 * (1 - phase); + final w = widths[k] * (1 - phase) + 11 * phase; + // Each line keeps its left edge, like text, until they meet in the middle. + final left = c.dx - widths[0] / 2 * (1 - phase) - w / 2 * phase; + paint.color = color.withValues(alpha: k == 1 ? 1 : 1 - 0.6 * phase); + canvas.drawLine(Offset(left, y), Offset(left + w, y), paint); + } + } + + @override + bool shouldRepaint(covariant _CondensingLinesPainter old) => old.t != t || old.color != color; +} + +/// A soft band of light that moves across [child] once per cycle of [motion]. +class _ReadingSweep extends StatelessWidget { + const _ReadingSweep({required this.motion, required this.still, required this.child}); + + final Animation motion; + final bool still; + final Widget child; + + @override + Widget build(BuildContext context) { + if (still) return child; + return RepaintBoundary( + child: AnimatedBuilder( + animation: motion, + child: child, + builder: (context, child) { + final x = -0.3 + 1.6 * Curves.easeInOut.transform(motion.value); + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (rect) => LinearGradient( + colors: [OmiColors.textTertiary, OmiColors.textPrimary, OmiColors.textTertiary], + stops: [(x - 0.2).clamp(0.0, 1.0), x.clamp(0.0, 1.0), (x + 0.2).clamp(0.0, 1.0)], + ).createShader(rect), + child: child, + ); + }, + ), + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 17a96bf029d..1501db0c2a4 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -21,7 +21,10 @@ import 'package:omi/pages/chat/chat_route.dart'; import 'package:omi/pages/chat/page.dart'; import 'package:omi/pages/action_items/widgets/task_selection_action_bar.dart'; import 'package:omi/pages/conversations/widgets/merge_action_bar.dart'; +import 'package:omi/pages/action_items/widgets/tasks_more_menu.dart'; import 'package:omi/pages/home/home_content.dart'; +import 'package:omi/pages/home/widgets/home_bottom_controls.dart'; +import 'package:omi/pages/home/widgets/home_page_stack.dart'; import 'package:omi/pages/home/widgets/home_tab_switcher.dart'; import 'package:omi/pages/home/home_widgets_publisher.dart'; import 'package:omi/pages/phone_calls/active_call_banner.dart'; @@ -65,6 +68,7 @@ import 'package:omi/widgets/calendar_date_picker_sheet.dart'; import 'package:omi/widgets/header_circle_button.dart'; import 'package:omi/pages/onboarding/interactive_device_onboarding/interactive_device_onboarding_wrapper.dart'; import 'package:omi/services/sockets/listen_client_state.dart'; +import 'package:omi/pages/home/paywall_reask_prompt.dart'; import 'package:omi/ui/ui.dart'; import 'home_deep_links.dart'; import 'home_navigation.dart'; @@ -368,6 +372,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Reload convos if (mounted) { + unawaited(queuePaywallReask(context)); Provider.of(context, listen: false).refreshConversations(); final captureProvider = Provider.of(context, listen: false); captureProvider.setMetricsAppActive(true); @@ -473,6 +478,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker listen: false, ).streamDeviceRecording(device: Provider.of(context, listen: false).capabilityNormalizedDevice); } + if (mounted) context.read().showDeferredFirmwareUpdatePrompt(context); if (!mounted || initialLink == null) return; await openHomeDeepLink(context, initialLink, openSettings: _openSettings, openSearch: _openSearch); @@ -563,6 +569,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker }, ); + unawaited(queuePaywallReask(context)); + // Register callback for device connection to check firmware announcements and device onboarding deviceProvider.onDeviceConnected = (BtDevice device) { _onDeviceConnectedForAnnouncements(device); @@ -775,7 +783,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker child: Scaffold( backgroundColor: OmiColors.surface0, resizeToAvoidBottomInset: false, - appBar: _buildAppBar(context, onHome), + appBar: _buildAppBar(context), body: GestureDetector( onTap: () => primaryFocus?.unfocus(), child: Stack( @@ -783,7 +791,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker Column( children: [ HomeTabSwitcher( - trailing: onHome ? null : _buildTasksActions(context), + trailing: _buildPageActions(context, onHome), onTabTap: (index, isRepeat) { if (isRepeat) { _scrollToTop(index); @@ -819,7 +827,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Home shows an active call in its capture row; Tasks gets the slim bar. if (!onHome) const ActiveCallTopBar(), Expanded( - child: IndexedStack(index: selectedIndex, children: _buildPages(selectedIndex)), + child: HomePageStack(index: selectedIndex, children: _buildPages(selectedIndex)), ), ], ), @@ -840,9 +848,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker bottom: homeChatBarOffset(context), child: Row( children: [ - Expanded(child: _buildChatBar(context)), + HomeChatButton(onTap: _openChat, onLongPress: () => _openChat(voice: true)), const SizedBox(width: 10), - const HomeRecordButton(), + const Expanded(child: HomeRecordButton()), ], ), ), @@ -878,99 +886,23 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker openChatSheet(context, ChatPage(isPivotBottom: false, autoStartVoice: voice)); } - Widget _buildChatBar(BuildContext context) { - return Semantics( - container: true, - button: true, - label: context.l10n.askOmi, - onTap: _openChat, - child: GestureDetector( - key: const ValueKey('home_ask_omi_bar'), - behavior: HitTestBehavior.opaque, - onTap: _openChat, - child: Container( - height: kHomeChatBarHeight, - decoration: BoxDecoration( - color: OmiColors.surface1, - borderRadius: OmiRadius.pillAll, - border: Border.all(color: OmiColors.border, width: 1), - ), - child: Row( - children: [ - const SizedBox(width: 18), - Expanded( - child: ExcludeSemantics( - child: Text( - context.l10n.askOmi, - style: OmiType.subhead.copyWith(color: OmiColors.textTertiary), - overflow: TextOverflow.ellipsis, - ), - ), - ), - GestureDetector( - // The mic sits inside the chat bar's own tap target, so a near miss - // does not do nothing: it opens text chat instead of voice. Own the - // bar's full height and its rounded end, not just the 42pt circle. - behavior: HitTestBehavior.opaque, - onTap: () => _openChat(voice: true), - child: Semantics( - container: true, - button: true, - label: context.l10n.voiceMode, - child: Container( - height: kHomeChatBarHeight, - padding: const EdgeInsets.only(left: 8, right: 6), - alignment: Alignment.center, - child: Container( - width: 42, - height: 42, - alignment: Alignment.center, - decoration: BoxDecoration(color: OmiColors.accent, shape: BoxShape.circle), - child: FaIcon(FontAwesomeIcons.microphone, size: 15, color: OmiColors.onAccent), - ), - ), - ), - ), - ], - ), - ), - ), - ); - } - - /// Export and the completed toggle, beside the switcher while Tasks is showing. - Widget _buildTasksActions(BuildContext context) { - return Consumer( - builder: (context, actionItemsProvider, _) { - final showCompleted = actionItemsProvider.showCompletedView; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - HeaderCircleButton( - semanticLabel: context.l10n.exportButton, - icon: FaIcon(FontAwesomeIcons.arrowUpFromBracket, size: 16, color: OmiColors.textSecondary), - onTap: () { + /// The selected page's one action, bare beside the switcher: Home filters by date, Tasks opens + /// its menu (select, group, completed, export). Cross-fades when the page changes. + Widget _buildPageActions(BuildContext context, bool onHome) { + return AnimatedSwitcher( + duration: OmiMotion.of(context).quick, + child: onHome + ? OmiIconButton( + key: const ValueKey('home_calendar_button'), + icon: const FaIcon(FontAwesomeIcons.calendar, size: 18), + label: context.l10n.filterByDate, + color: OmiColors.textSecondary, + onPressed: () { OmiHaptics.selection(); - PlatformManager.instance.analytics.exportTasksBannerClicked(); - routeToPage(context, const TaskIntegrationsPage()); + unawaited(_openCalendar(context)); }, - ), - HeaderCircleButton( - semanticLabel: context.l10n.completed, - color: showCompleted ? OmiColors.surface3 : OmiColors.surface1, - icon: FaIcon( - FontAwesomeIcons.solidCircleCheck, - size: 16, - color: showCompleted ? OmiColors.textPrimary : OmiColors.textSecondary, - ), - onTap: () { - OmiHaptics.light(); - actionItemsProvider.toggleShowCompletedView(); - }, - ), - ], - ); - }, + ) + : const TasksMoreMenu(), ); } @@ -991,7 +923,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ); } - PreferredSizeWidget _buildAppBar(BuildContext context, bool onHome) { + /// The shell header: device, search and settings, the same on Home and Tasks. Page actions sit + /// beside the switcher instead (see [_buildPageActions]). + PreferredSizeWidget _buildAppBar(BuildContext context) { return AppBar( automaticallyImplyLeading: false, backgroundColor: Theme.of(context).colorScheme.surface, @@ -1009,16 +943,6 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), Row( children: [ - if (onHome) - HeaderCircleButton( - key: const ValueKey('home_calendar_button'), - semanticLabel: context.l10n.filterByDate, - icon: FaIcon(FontAwesomeIcons.calendar, size: 16, color: OmiColors.textSecondary), - onTap: () { - OmiHaptics.selection(); - unawaited(_openCalendar(context)); - }, - ), // Search: conversations, recaps, tasks and memories, from any page of the shell. HeaderCircleButton( key: const ValueKey('home_search_button'), diff --git a/app/lib/pages/home/paywall_reask_prompt.dart b/app/lib/pages/home/paywall_reask_prompt.dart new file mode 100644 index 00000000000..55bc80de1e9 --- /dev/null +++ b/app/lib/pages/home/paywall_reask_prompt.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/paywall_reask.dart'; +import 'package:omi/ui/prompts/prompt_queue.dart'; + +bool _checking = false; + +bool _isFree(UsageProvider usage) => + usage.showSubscriptionUI && usage.subscription?.subscription.plan == PlanType.basic; + +/// On app open (launch or back from the background): when [PaywallReask] says a re-ask is due for +/// this free reader and their arm shows it, queue the paywall. [PromptQueue] holds it while the +/// reader records, is on a call or another prompt is up, and drops it if they subscribed meanwhile. +Future queuePaywallReask(BuildContext context) async { + if (_checking) return; + _checking = true; + try { + final usage = context.read(); + if (usage.subscription == null) await usage.fetchSubscription(); + if (!context.mounted || !_isFree(usage)) return; + if (!await PaywallReask.checkDue() || !context.mounted) return; + PromptQueue.instance.enqueue( + PaywallReask.promptId, + PromptPriority.low, + canShowNow: () => context.mounted && _isFree(context.read()), + show: (promptContext) => openUpgradePaywall( + promptContext, + source: 'app_open_reask', + ), + ); + } finally { + _checking = false; + } +} diff --git a/app/lib/pages/home/widgets/battery_info_widget.dart b/app/lib/pages/home/widgets/battery_info_widget.dart index 287636cf480..20cab46a184 100644 --- a/app/lib/pages/home/widgets/battery_info_widget.dart +++ b/app/lib/pages/home/widgets/battery_info_widget.dart @@ -1,6 +1,7 @@ import 'package:omi/pages/phone_calls/phone_calls_feature.dart'; import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; import 'package:provider/provider.dart'; @@ -9,7 +10,11 @@ import 'package:omi/backend/schema/bt_device/bt_device.dart'; import 'package:omi/gen/assets.gen.dart'; import 'package:omi/pages/capture/connect.dart'; import 'package:omi/pages/conversation_capturing/page.dart'; +import 'package:omi/pages/conversations/live_capture_summary.dart'; +import 'package:omi/pages/home/widgets/home_capture_sheet.dart'; import 'package:omi/pages/home/device.dart'; +import 'package:omi/pages/home/widgets/home_bottom_controls.dart'; +import 'package:omi/pages/home/widgets/grow_route.dart'; import 'package:omi/pages/phone_calls/phone_calls_page.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/device_provider.dart'; @@ -33,8 +38,6 @@ const EdgeInsets _pillTargetMargin = EdgeInsets.symmetric(vertical: (kMinTapTarg /// Battery at or below this shows a low-battery glyph and a warning colour, not just a dot. const int kLowBatteryPercent = 20; -const _stopGlyphRadius = BorderRadius.all(Radius.circular(4)); // omi-ux-allow: radius-literal -- a glyph - class BatteryInfoWidget extends StatefulWidget { const BatteryInfoWidget({super.key}); @@ -175,12 +178,9 @@ class _BatteryInfoWidgetState extends State { children: [ // The asset is white; tint it so it shows on the light pill too. Image.asset(Assets.images.logoTransparent.path, width: 16, height: 16, color: OmiColors.textPrimary), - // Home has room for the word; the other tabs keep the logo alone (it still has a - // spoken label). - if (isMemoriesPage) ...[ - const SizedBox(width: 6), - Text(label, style: OmiType.caption.copyWith(fontSize: 12)), - ], + // The word stays on every page: the logo alone reads as a loading spinner. + const SizedBox(width: 6), + Text(label, style: OmiType.caption.copyWith(fontSize: 12)), ], ); } @@ -222,10 +222,13 @@ class _DevicePill extends StatelessWidget { } } -/// Circular phone-mic record button shown to the right of the home chat bar. -/// Tap starts/stops recording; long-press opens the record options sheet (phone call). The options -/// are announced as the long-press action, and a one-time tip points them out after the first -/// recording. +/// Home's main bottom control, a light pill beside Ask Omi: +/// - "+ New" starts a recording with the phone, and the live transcript grows out of the pill; +/// - "Stop" while the phone records (its status is the card at the top of Home); +/// - with a device connected, the device itself: its picture, what it is doing and its battery. +/// Tap opens what it is capturing; hold offers the phone instead. +/// Long-press on "New" opens the record options sheet (phone call) when calls are offered, and a +/// one-time tip points that out after the first recording. class HomeRecordButton extends StatefulWidget { const HomeRecordButton({super.key}); @@ -236,6 +239,17 @@ class HomeRecordButton extends StatefulWidget { class _HomeRecordButtonState extends State { static const _optionsTipKey = 'v2/homeRecordOptionsTipShown'; + /// Finishing takes a moment (the last audio is flushed): the pill says so at once. + bool _stopping = false; + + /// The phone recording is this pill's own: live, paused, or reconnecting to transcription. + static bool _phoneOwnsCapture(CaptureProvider capture) { + final state = capture.recordingState; + return state == RecordingState.record || + capture.isPhoneMicPaused || + (state == RecordingState.interrupted && capture.liveCaptureSource == 'phone'); + } + void _showRecordOptions(BuildContext context) { OmiHaptics.light(); SharedPreferencesUtil().saveBool(_optionsTipKey, true); @@ -322,10 +336,16 @@ class _HomeRecordButtonState extends State { Future _startPhoneRecording(BuildContext context) async { final captureProvider = context.read(); OmiHaptics.medium(); - if (captureProvider.recordingState == RecordingState.record || captureProvider.isPhoneMicPaused) { + if (_phoneOwnsCapture(captureProvider)) { // The phone recording is this button's own: finish it (processed, except Transcribe Later, // whose local file is finalized on stop), then any pendant it paused resumes. - await captureProvider.finishCapture(); + if (_stopping) return; + setState(() => _stopping = true); + try { + await captureProvider.finishCapture(); + } finally { + if (mounted) setState(() => _stopping = false); + } PlatformManager.instance.analytics.phoneMicRecordingStopped(); if (context.mounted) _maybeShowOptionsTip(context); return; @@ -354,104 +374,229 @@ class _HomeRecordButtonState extends State { } return; } - if (context.mounted) { - routeToPage(context, ConversationCapturingPage(topConversationId: captureProvider.topConversationId)); + if (context.mounted) _openTranscript(context); + } + + /// The pendant capture the pill last showed, so a pendant that drops mid-capture reads as + /// Disconnected instead of the pill going back to New. The controller forgets the device on + /// disconnect, so "was capturing" is remembered here and cleared when it reconnects, is unpaired, + /// or another source takes over. + String? _droppedSource; + + bool _trackPendantDrop(CaptureProvider capture, {required bool connected, required bool paired}) { + final source = capture.liveCaptureSource; + if (source != null && source != 'phone' && !SharedPreferencesUtil().batchModeEnabled) { + _droppedSource = source; + return false; + } + if (source != null || connected || !paired) { + _droppedSource = null; + return false; } + return _droppedSource != null; + } + + /// The status the pill shows: the state and its short detail ("Recording · Audio saved…"). + static String _pillStatus(LiveCaptureSummary summary) { + final detail = summary.copy.detail; + if (detail == null || detail.isEmpty) return summary.copy.status; + return '${summary.copy.status} · $detail'; + } + + Future _resume(BuildContext context) async { + OmiHaptics.medium(); + try { + await context.read().resumeCapture(); + PlatformManager.instance.analytics.phoneMicRecordingStarted(); + } catch (_) { + if (context.mounted) OmiFeedback.error(context, context.l10n.somethingWentWrong); + } + } + + /// Everything the old Home card said and offered, on demand: why (when something needs a look), + /// the capture's own controls (Pause/Resume, Transcribe Later's New recording and Stop, retrying + /// transcription), and the way to the live transcript. + void _showCaptureSheet(BuildContext context, LiveCaptureSummary? summary) { + final l10n = context.l10n; + final capture = context.read(); + final phoneLive = _phoneOwnsCapture(capture) && !capture.isPhoneMicBatchRecording; + showOmiSheet( + context: context, + title: summary?.copy.status ?? l10n.listening, + builder: (sheetContext) => HomeCaptureSheet( + explanation: summary?.copy.explanation, + onOpenTranscript: summary != null && !summary.batch + ? () { + Navigator.pop(sheetContext); + if (context.mounted) _openTranscript(context); + } + : null, + onStop: phoneLive + ? () { + Navigator.pop(sheetContext); + if (context.mounted) _startRecording(context); + } + : null, + ), + ); + } + + void _showDroppedSheet(BuildContext context) { + showOmiSheet( + context: context, + title: context.l10n.disconnected, + builder: (_) => HomeCaptureSheet(explanation: context.l10n.capturePendantDisconnectedDetail, showCard: false), + ); + } + + final GlobalKey _pillKey = GlobalKey(); + + /// The live transcript, grown out of this pill. + void _openTranscript(BuildContext context) { + final capture = context.read(); + pushGrowingFrom( + context, + from: _pillKey, + page: ConversationCapturingPage(topConversationId: capture.topConversationId), + ); + } + + /// The capturing pendant's pill: its live transcript, or the call while an Omi call owns it. + void _openDevice(BuildContext context) { + OmiHaptics.selection(); + if (_callInProgress(context)) { + routeToPage(context, const ActiveCallPage()); + return; + } + final capture = context.read(); + PlatformManager.instance.analytics.liveTranscriptCardClicked( + hasSegments: capture.segments.isNotEmpty, + hasPhotos: capture.photos.isNotEmpty, + segmentCount: capture.segments.length, + photoCount: capture.photos.length, + ); + _openTranscript(context); } @override Widget build(BuildContext context) { - return Consumer( - builder: (context, captureProvider, _) { - // The phone recording is this button's own (live or paused); anything else is idle here. - final isRecording = captureProvider.recordingState == RecordingState.record || captureProvider.isPhoneMicPaused; - final isInitialising = captureProvider.recordingState == RecordingState.initialising; - // The options sheet only adds a phone call; with calls hidden there is nothing else to offer. - final canShowOptions = PhoneCallsFeature.visible && !isRecording && !isInitialising; - final l10n = context.l10n; - final circle = Semantics( - button: true, - label: isRecording ? l10n.stopRecording : l10n.startRecording, - enabled: !isInitialising, - // The child's gestures are excluded below, so the actions (tap, and long-press for the - // record options) are declared here where assistive tech can reach them. - onTap: isInitialising ? null : () => _startRecording(context), - onLongPress: canShowOptions ? () => _showRecordOptions(context) : null, - onLongPressHint: canShowOptions ? l10n.moreOptions : null, - excludeSemantics: true, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _startRecording(context), - onLongPress: canShowOptions ? () => _showRecordOptions(context) : null, - // "Record with this phone": a neutral circle with a white dot, a stop square while the - // phone records. The ⌄ badge opens the other ways to record. - child: Container( - width: 62, - height: 62, - alignment: Alignment.center, - decoration: BoxDecoration( - color: OmiColors.surface1, - shape: BoxShape.circle, - border: Border.all(color: OmiColors.border, width: 1), - ), - child: isRecording - ? Container( - width: 18, - height: 18, - decoration: BoxDecoration( - color: OmiColors.textPrimary, - borderRadius: _stopGlyphRadius, - ), - ) - : isInitialising - ? const OmiSpinner(size: OmiSpinnerSize.small) - : Container( - width: 20, - height: 20, - decoration: BoxDecoration(color: OmiColors.textPrimary, shape: BoxShape.circle), - ), - ), - ), - ); - // The badge is its own control (and its own accessibility node) beside the circle's. - return SizedBox( - width: 62, - height: 62, - child: Stack(clipBehavior: Clip.none, children: [ - circle, - if (canShowOptions) - Positioned( - right: 0, - bottom: 0, - child: Semantics( - button: true, - label: l10n.moreWaysToRecord, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => _showRecordOptions(context), - // A 30pt hit area around the 22pt badge, inside the button's 62pt box (a Stack - // does not hit-test outside its bounds, and 44pt would cover the circle's - // centre). Screen readers also get the options as the circle's long-press action. - child: Padding( - padding: const EdgeInsets.all(4), - child: Container( - width: 22, - height: 22, - alignment: Alignment.center, - decoration: BoxDecoration( - color: OmiColors.surface3, - shape: BoxShape.circle, - border: Border.all(color: OmiColors.surface0, width: 2), - ), - child: Icon(Icons.keyboard_arrow_down_rounded, size: 16, color: OmiColors.textPrimary), - ), - ), - ), - ), + final l10n = context.l10n; + final capture = context.watch(); + final device = context.select((d) => d.connectedDevice); + final starting = capture.recordingState == RecordingState.initialising; + // The pendant stands in for "New" only while it is capturing live; connected but idle (or + // saving for Transcribe Later), the phone is still one tap away and the header shows the device. + final pendantCapturing = device != null && _pendantHasCapture(capture); + // A phone recording that is starting already looks live, so the pill changes the moment it is + // tapped instead of after the microphone is up (Home shows no separate card meanwhile). + final phoneRecording = _phoneOwnsCapture(capture) || (starting && !pendantCapturing); + // The options sheet only adds a phone call; with calls hidden there is nothing else to offer. + final canShowOptions = PhoneCallsFeature.visible && !phoneRecording && !starting; + + // What is recording and how it is going: the same summary the details sheet shows. + final summary = summarizeLiveCapture(l10n, capture); + final paired = context.select((d) => d.pairedDevice); + final dropped = _trackPendantDrop(capture, connected: device != null, paired: paired != null); + final pendantBatch = summary != null && summary.batch && !summary.isPhone; + + final Widget pill; + if (phoneRecording) { + void stop() => _startRecording(context); + final batch = capture.isPhoneMicBatchRecording; + final resumable = summary != null && summary.readerPaused && !batch; + final status = summary == null ? l10n.listening : _pillStatus(summary); + final healthy = summary == null || summary.healthy; + pill = HomeControlSurface( + key: const ValueKey('home_record_pill_stop'), + semanticsLabel: status, + semanticsHint: healthy ? l10n.liveTranscript : l10n.moreOptions, + semanticsActions: _stopping ? null : {CustomSemanticsAction(label: l10n.stopRecording): stop}, + onTap: _stopping + ? null + : () { + OmiHaptics.selection(); + healthy ? _openTranscript(context) : _showCaptureSheet(context, summary); + }, + child: HomeLivePillContent( + status: status, + lastLine: batch ? null : capture.segments.lastOrNull?.text, + live: summary == null || summary.tone == CaptureTone.live, + attention: summary?.tone == CaptureTone.attention, + stopping: _stopping, + onStop: stop, + onResume: resumable ? () => _resume(context) : null, + ), + ); + } else if ((pendantCapturing || pendantBatch) && !starting && summary != null) { + final shown = device ?? paired; + final status = _pillStatus(summary); + final open = summary.healthy ? () => _openDevice(context) : () => _showCaptureSheet(context, summary); + pill = HomeControlSurface( + key: const ValueKey('home_record_pill_device'), + semanticsLabel: shown == null ? status : '${shown.name}, $status', + semanticsHint: summary.healthy ? l10n.liveTranscript : l10n.moreOptions, + onTap: () { + OmiHaptics.selection(); + open(); + }, + // Holding it offers the phone instead (or a call), as tapping record used to. + onLongPress: pendantCapturing ? () => _showPendantListening(context) : null, + longPressHint: pendantCapturing ? l10n.moreOptions : null, + child: shown == null + ? HomeLivePillContent( + status: status, + live: summary.tone == CaptureTone.live, + attention: summary.tone == CaptureTone.attention, + onStop: null, + ) + : HomeDevicePillContent( + device: shown, + status: status, + live: summary.tone == CaptureTone.live, + attention: summary.tone == CaptureTone.attention, + lastLine: summary.batch ? null : capture.segments.lastOrNull?.text, ), - ]), - ); - }, + ); + } else if (dropped && paired != null) { + // The pendant dropped mid-capture: say so here (it reconnects on its own) instead of a card. + final connecting = context.select((d) => d.isConnecting); + final status = '${l10n.disconnected} · ${connecting ? l10n.reconnecting : l10n.capturePendantDisconnectedShort}'; + pill = HomeControlSurface( + key: const ValueKey('home_record_pill_dropped'), + semanticsLabel: '${paired.name}, $status', + semanticsHint: l10n.moreOptions, + onTap: () { + OmiHaptics.selection(); + _showDroppedSheet(context); + }, + child: HomeDevicePillContent(device: paired, status: status, live: false, attention: true), + ); + } else { + pill = HomeControlSurface( + key: const ValueKey('home_record_pill_new'), + semanticsLabel: l10n.newRecording, + onTap: starting ? null : () => _startRecording(context), + onLongPress: canShowOptions ? () => _showRecordOptions(context) : null, + longPressHint: l10n.moreWaysToRecord, + child: HomeNewPillContent(starting: starting), + ); + } + + return KeyedSubtree( + key: _pillKey, + child: AnimatedSwitcher( + duration: OmiMotion.of(context).standard, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + // Cross-fade with a slight rise, so a change of state reads as the same control updating. + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition(scale: Tween(begin: 0.98, end: 1.0).animate(animation), child: child), + ), + layoutBuilder: (current, previous) => + Stack(alignment: Alignment.center, children: [...previous, if (current != null) current]), + child: pill, + ), ); } } diff --git a/app/lib/pages/home/widgets/grow_route.dart b/app/lib/pages/home/widgets/grow_route.dart new file mode 100644 index 00000000000..be132f370ec --- /dev/null +++ b/app/lib/pages/home/widgets/grow_route.dart @@ -0,0 +1,157 @@ +import 'dart:ui' show lerpDouble; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +import 'package:omi/ui/ui.dart'; + +/// Opens [page] by growing it out of the control that asked for it: the page's shape starts as +/// that control (its rect and pill corners, in [fromColor]) and widens to the full screen while +/// the page fades in over it. A back tap runs it back into the control; the iOS edge swipe still +/// slides it away like any page. Under Reduce Motion it is a plain fade. +/// +/// The page is laid out at full size the whole time and only clipped, so nothing inside it +/// reflows while it grows. +Future pushGrowingFrom(BuildContext context, {required GlobalKey from, required Widget page, Color? fromColor}) { + final box = from.currentContext?.findRenderObject() as RenderBox?; + final origin = box != null && box.hasSize ? box.localToGlobal(Offset.zero) & box.size : null; + return Navigator.of(context).push( + _GrowRoute( + page: page, + origin: origin, + fromColor: fromColor ?? OmiColors.surface1, + reduceMotion: OmiMotion.of(context).standard == Duration.zero, + ), + ); +} + +class _GrowRoute extends PageRoute with CupertinoRouteTransitionMixin { + _GrowRoute({required this.page, required this.origin, required this.fromColor, required this.reduceMotion}); + + final Widget page; + final Rect? origin; + final Color fromColor; + final bool reduceMotion; + + /// Set once an edge swipe starts, so the rest of that dismissal stays a slide instead of + /// switching to the shrink halfway through. + bool _swiping = false; + + @override + Widget buildContent(BuildContext context) => page; + + @override + String? get title => null; + + @override + bool get maintainState => true; + + @override + Duration get transitionDuration => reduceMotion ? OmiMotion.quickDuration : const Duration(milliseconds: 460); + + @override + Duration get reverseTransitionDuration => reduceMotion ? OmiMotion.quickDuration : const Duration(milliseconds: 380); + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (popGestureInProgress) _swiping = true; + if (animation.status == AnimationStatus.completed) _swiping = false; + // One widget structure in every mode, so the page keeps its state when a swipe takes over: + // the Cupertino layer (back swipe, parallax under later pages) holds still until a swipe + // starts, and the grow layer steps aside while one runs. + return _Grow( + animation: animation, + origin: origin, + fromColor: fromColor, + mode: _swiping + ? _GrowMode.off + : reduceMotion || origin == null + ? _GrowMode.fade + : _GrowMode.grow, + child: super.buildTransitions( + context, + _swiping ? animation : kAlwaysCompleteAnimation, + secondaryAnimation, + child, + ), + ); + } +} + +enum _GrowMode { grow, fade, off } + +class _Grow extends StatelessWidget { + const _Grow({ + required this.animation, + required this.origin, + required this.fromColor, + required this.mode, + required this.child, + }); + + final Animation animation; + final Rect? origin; + final Color fromColor; + final _GrowMode mode; + final Widget child; + + @override + Widget build(BuildContext context) { + final screen = Offset.zero & MediaQuery.sizeOf(context); + // The shape moves on an emphasized curve that leaves and lands softly (mirrored on the way + // back); the page fades in only once there is room to read it. + final shape = CurvedAnimation( + parent: animation, + curve: Curves.easeInOutCubicEmphasized, + reverseCurve: Curves.easeInOutCubicEmphasized.flipped, + ); + final content = mode == _GrowMode.grow + ? CurvedAnimation(parent: animation, curve: const Interval(0.3, 0.85, curve: Curves.easeOut)) + : mode == _GrowMode.fade + ? animation + : kAlwaysCompleteAnimation; + final pageColor = Theme.of(context).scaffoldBackgroundColor; + final faded = FadeTransition(opacity: content, child: child); + // The shape itself fades in over the first stretch (out over the last, on close), so the real + // pill underneath shows through where the two overlap instead of a blank copy of it. + final handoff = mode == _GrowMode.grow + ? CurvedAnimation(parent: animation, curve: const Interval(0, 0.18, curve: Curves.easeOut)) + : kAlwaysCompleteAnimation; + final shaped = AnimatedBuilder( + animation: animation, + child: faded, + builder: (context, faded) { + final growing = mode == _GrowMode.grow; + final t = growing ? shape.value : 1.0; + final from = origin ?? screen; + final rect = Rect.lerp(from, screen, t)!; + final radius = lerpDouble(from.shortestSide / 2, 0, t)!; + return ClipRRect( + clipper: _RectClipper(RRect.fromRectAndRadius(rect, Radius.circular(radius))), + child: ColoredBox( + color: growing ? Color.lerp(fromColor, pageColor, content.value)! : Colors.transparent, + child: faded, + ), + ); + }, + ); + return FadeTransition(opacity: handoff, child: shaped); + } +} + +class _RectClipper extends CustomClipper { + const _RectClipper(this.rrect); + + final RRect rrect; + + @override + RRect getClip(Size size) => rrect; + + @override + bool shouldReclip(_RectClipper oldClipper) => oldClipper.rrect != rrect; +} diff --git a/app/lib/pages/home/widgets/home_bottom_controls.dart b/app/lib/pages/home/widgets/home_bottom_controls.dart new file mode 100644 index 00000000000..5f1db5591d1 --- /dev/null +++ b/app/lib/pages/home/widgets/home_bottom_controls.dart @@ -0,0 +1,457 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart'; + +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/device.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/widgets/home_bottom_bar.dart'; + +/// The light surface Home's bottom controls share: white on the light canvas, a step up from the +/// page in dark mode. It presses in slightly under the finger. +class HomeControlSurface extends StatefulWidget { + const HomeControlSurface({ + super.key, + required this.child, + required this.semanticsLabel, + this.semanticsHint, + this.onTap, + this.onLongPress, + this.longPressHint, + this.width, + this.semanticsActions, + }); + + final Widget child; + final String semanticsLabel; + final String? semanticsHint; + final VoidCallback? onTap; + final VoidCallback? onLongPress; + final String? longPressHint; + + /// Fixed width (a circle); null fills the space it is given (the pill). + final double? width; + + /// Actions of controls inside the surface, which its single label otherwise hides (the live + /// pill's stop button). + final Map? semanticsActions; + + // White on the canvas like Home's cards, lifted by a soft, wide shadow because the controls + // float over the list (a hairline would read as a form field). + static BoxDecoration decoration() => BoxDecoration( + color: OmiColors.surface1, + borderRadius: OmiRadius.pillAll, + boxShadow: [ + BoxShadow(color: Colors.black.withValues(alpha: 0.06), blurRadius: 24, offset: const Offset(0, 6)), + BoxShadow(color: Colors.black.withValues(alpha: 0.04), blurRadius: 4, offset: const Offset(0, 1)), + ], + ); + + @override + State createState() => _HomeControlSurfaceState(); +} + +class _HomeControlSurfaceState extends State { + bool _pressed = false; + + void _press(bool down) { + if (_pressed != down) setState(() => _pressed = down); + } + + @override + Widget build(BuildContext context) { + final motion = OmiMotion.of(context); + return Semantics( + container: true, + button: true, + enabled: widget.onTap != null, + label: widget.semanticsLabel, + hint: widget.semanticsHint, + onTap: widget.onTap, + onLongPress: widget.onLongPress, + onLongPressHint: widget.onLongPress == null ? null : widget.longPressHint, + customSemanticsActions: widget.semanticsActions, + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: widget.onTap == null ? null : (_) => _press(true), + onTapUp: (_) => _press(false), + onTapCancel: () => _press(false), + onTap: widget.onTap, + onLongPress: widget.onLongPress, + child: AnimatedScale( + scale: _pressed ? 0.96 : 1, + duration: motion.quick, + curve: Curves.easeOut, + child: Container( + width: widget.width, + height: kHomeChatBarHeight, + decoration: HomeControlSurface.decoration(), + child: widget.child, + ), + ), + ), + ); + } +} + +/// Ask Omi: a quiet circle at the left of the record pill. Tap to type, hold to talk. +class HomeChatButton extends StatelessWidget { + const HomeChatButton({super.key, required this.onTap, required this.onLongPress}); + + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return HomeControlSurface( + key: const ValueKey('home_ask_omi_button'), + width: kHomeChatBarHeight, + semanticsLabel: l10n.askOmi, + onTap: onTap, + onLongPress: onLongPress, + longPressHint: l10n.voiceMode, + child: Center(child: FaIcon(FontAwesomeIcons.solidComment, size: 22, color: OmiColors.textPrimary)), + ); + } +} + +/// A 28pt ink disc holding a glyph: the pill's one point of contrast, so it reads as the +/// primary control without turning the whole bar dark. +class _Disc extends StatelessWidget { + const _Disc({required this.child, this.color}); + + final Widget child; + final Color? color; + + @override + Widget build(BuildContext context) { + return Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration(color: color ?? OmiColors.accent, shape: BoxShape.circle), + child: child, + ); + } +} + +TextStyle _pillLabel() => OmiType.headline.copyWith(fontWeight: FontWeight.w500, color: OmiColors.textPrimary); + +/// "+ New Recording": start a recording with this phone. While the microphone opens, a spinner +/// takes the plus's place. +class HomeNewPillContent extends StatelessWidget { + const HomeNewPillContent({super.key, required this.starting}); + + final bool starting; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + child: Center( + child: starting + ? OmiSpinner(size: OmiSpinnerSize.small, color: OmiColors.textPrimary) + : Icon(Icons.add_rounded, size: 26, color: OmiColors.textPrimary), + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + context.l10n.homeNewRecordingButton, + style: _pillLabel(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + // Optical centre: the plus is lighter than the words, so the pair sits a hair right. + const SizedBox(width: 4), + ], + ), + ); + } +} + +/// The phone recording, in place of "New": a breathing dot, what it is doing and the newest words +/// (the pill opens the live transcript), with its own stop button at the end. While the recording +/// finishes, the square becomes a spinner. +class HomeLivePillContent extends StatelessWidget { + const HomeLivePillContent({ + super.key, + required this.status, + required this.onStop, + this.lastLine, + this.live = true, + this.attention = false, + this.stopping = false, + this.onResume, + }); + + final String status; + final String? lastLine; + final bool live; + + /// Something the person should look at (tap the pill for why): an orange dot. + final bool attention; + final bool stopping; + final VoidCallback? onStop; + + /// Paused by the person: the one control is Resume (Stop is in the sheet the pill opens). + final VoidCallback? onResume; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const SizedBox(width: OmiSpacing.md + 2), + HomeLiveDot(live: live, attention: attention), + const SizedBox(width: OmiSpacing.sm), + Expanded(child: _StatusAndWords(status: status, lastLine: lastLine)), + if (onResume != null) + Semantics( + button: true, + label: context.l10n.resume, + child: GestureDetector( + key: const ValueKey('home_record_pill_resume_button'), + behavior: HitTestBehavior.opaque, + onTap: onResume, + child: SizedBox( + width: kHomeChatBarHeight, + height: kHomeChatBarHeight, + child: Center( + child: _Disc( + color: OmiColors.textPrimary, + child: Icon(Icons.play_arrow_rounded, size: 20, color: OmiColors.surface1), + ), + ), + ), + ), + ) + else + Semantics( + button: true, + label: context.l10n.stopRecording, + child: GestureDetector( + key: const ValueKey('home_record_pill_stop_button'), + behavior: HitTestBehavior.opaque, + onTap: stopping ? null : onStop, + child: SizedBox( + width: kHomeChatBarHeight, + height: kHomeChatBarHeight, + child: Center( + child: _Disc( + color: OmiColors.danger, + child: stopping + ? const OmiSpinner(size: OmiSpinnerSize.small, color: Colors.white) + : Container( + width: 10, + height: 10, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.all(Radius.circular(2.5)), // omi-ux-allow: radius-literal -- a glyph + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// One line of status, or, once words arrive, the status small above the newest words. The words +/// run in from the right and fade out at the left, so the latest is always what shows. +class _StatusAndWords extends StatelessWidget { + const _StatusAndWords({required this.status, this.lastLine, this.title}); + + final String status; + final String? lastLine; + + /// The device name, before the status. + final String? title; + + @override + Widget build(BuildContext context) { + final words = lastLine?.trim() ?? ''; + final secondary = OmiType.headline.copyWith(fontWeight: FontWeight.w400, color: OmiColors.textSecondary); + if (words.isEmpty) { + return Text.rich( + TextSpan(children: [ + if (title != null) TextSpan(text: '$title ', style: _pillLabel()), + TextSpan(text: status, style: title == null ? _pillLabel() : secondary), + ]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title == null ? status : '$title · $status', + style: OmiType.caption.copyWith(color: OmiColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox( + height: 20, + child: ShaderMask( + shaderCallback: (rect) => const LinearGradient( + colors: [Colors.transparent, Colors.black], + stops: [0, 0.16], + ).createShader(rect), + blendMode: BlendMode.dstIn, + child: ClipRect( + child: OverflowBox( + maxWidth: double.infinity, + alignment: Alignment.centerRight, + child: Text( + words, + key: const ValueKey('home_pill_last_words'), + style: OmiType.callout.copyWith(color: OmiColors.textPrimary), + maxLines: 1, + softWrap: false, + ), + ), + ), + ), + ), + ], + ); + } +} + +/// The connected device in place of "New": its picture with a live dot, its name and what it is +/// doing. (Its battery is in the header pill.) +class HomeDevicePillContent extends StatelessWidget { + const HomeDevicePillContent({ + super.key, + required this.device, + required this.status, + required this.live, + this.lastLine, + this.attention = false, + }); + + final BtDevice device; + + /// Something the person should look at: an orange dot. + final bool attention; + final String status; + final String? lastLine; + + /// Capturing now: the dot is red and breathes; otherwise it is a still grey dot. + final bool live; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.md), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 28, + height: 28, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned.fill( + child: Image.asset( + DeviceUtils.getDeviceImagePath( + deviceType: device.type, + modelNumber: device.modelNumber, + deviceName: device.name, + ), + fit: BoxFit.contain, + ), + ), + Positioned(right: -2, bottom: -1, child: HomeLiveDot(live: live, attention: attention)), + ], + ), + ), + const SizedBox(width: OmiSpacing.sm), + Flexible(child: _StatusAndWords(title: device.name, status: status, lastLine: lastLine)), + ], + ), + ); + } +} + +/// A 10pt status dot ringed in the pill's colour so it reads on top of the device picture. Live: +/// red, breathing (still under Reduce Motion). Idle or paused: grey. Needs a look: orange, still. +class HomeLiveDot extends StatefulWidget { + const HomeLiveDot({super.key, required this.live, this.attention = false}); + + final bool live; + final bool attention; + + @override + State createState() => _HomeLiveDotState(); +} + +class _HomeLiveDotState extends State with SingleTickerProviderStateMixin { + late final AnimationController _breath = + AnimationController(vsync: this, duration: const Duration(milliseconds: 1400), lowerBound: 0.4, value: 1); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _sync(); + } + + @override + void didUpdateWidget(HomeLiveDot oldWidget) { + super.didUpdateWidget(oldWidget); + _sync(); + } + + void _sync() { + final breathe = widget.live && !widget.attention && OmiMotion.of(context).standard != Duration.zero; + if (breathe && !_breath.isAnimating) { + _breath.repeat(reverse: true); + } else if (!breathe) { + _breath + ..stop() + ..value = 1; + } + } + + @override + void dispose() { + _breath.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: 12, + height: 12, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration(color: OmiColors.surface1, shape: BoxShape.circle), + child: FadeTransition( + opacity: _breath, + child: DecoratedBox( + decoration: BoxDecoration( + color: widget.attention ? OmiColors.warning : (widget.live ? OmiColors.danger : OmiColors.textTertiary), + shape: BoxShape.circle, + ), + ), + ), + ); + } +} diff --git a/app/lib/pages/home/widgets/home_capture_sheet.dart b/app/lib/pages/home/widgets/home_capture_sheet.dart new file mode 100644 index 00000000000..c79d180ba5f --- /dev/null +++ b/app/lib/pages/home/widgets/home_capture_sheet.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/pages/conversations/widgets/processing_capture.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +/// What Home's recording pill opens when there is more to say than "Listening": why something +/// needs a look, the capture's own controls (the live capture card: Pause/Resume, Transcribe +/// Later's New recording and Stop, retrying transcription), the live transcript, and Stop. +class HomeCaptureSheet extends StatelessWidget { + const HomeCaptureSheet({ + super.key, + this.explanation, + this.onOpenTranscript, + this.onStop, + this.showCard = true, + }); + + final String? explanation; + final VoidCallback? onOpenTranscript; + final VoidCallback? onStop; + + /// False when the capture is gone (a pendant that dropped): only the explanation is left to show. + final bool showCard; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, 0, OmiSpacing.lg, OmiSpacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (explanation != null) ...[ + Text( + explanation!, + key: const ValueKey('home_capture_sheet_explanation'), + style: OmiType.body.copyWith(color: OmiColors.textSecondary), + ), + const SizedBox(height: OmiSpacing.md), + ], + if (showCard) const ConversationCaptureWidget(), + if (onOpenTranscript != null) ...[ + const SizedBox(height: OmiSpacing.md), + OmiButton.secondary( + key: const ValueKey('home_capture_sheet_transcript'), + label: l10n.liveTranscript, + onPressed: onOpenTranscript, + expand: true, + ), + ], + if (onStop != null) ...[ + const SizedBox(height: OmiSpacing.sm), + OmiButton.secondary( + key: const ValueKey('home_capture_sheet_stop'), + label: l10n.stopRecording, + icon: Icons.stop_rounded, + onPressed: onStop, + expand: true, + ), + ], + ], + ), + ); + } +} diff --git a/app/lib/pages/home/widgets/home_page_stack.dart b/app/lib/pages/home/widgets/home_page_stack.dart new file mode 100644 index 00000000000..db9a832cfb6 --- /dev/null +++ b/app/lib/pages/home/widgets/home_page_stack.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/ui/ui.dart'; + +/// Home and Tasks as an [IndexedStack] (both keep their state and scroll position), with the +/// page that comes in fading and sliding a short way from the side of its tab: Tasks from the +/// right, Home from the left. Reduce Motion switches without the animation. +class HomePageStack extends StatefulWidget { + const HomePageStack({super.key, required this.index, required this.children}); + + final int index; + final List children; + + @override + State createState() => _HomePageStackState(); +} + +class _HomePageStackState extends State with SingleTickerProviderStateMixin { + /// How far the incoming page travels, as a fraction of its width. + static const _travel = 0.06; + + late final AnimationController _controller = AnimationController(vsync: this, value: 1); + late final Animation _fade = CurvedAnimation(parent: _controller, curve: OmiMotion.emphasizedCurve); + late Animation _slide = const AlwaysStoppedAnimation(Offset.zero); + + @override + void didUpdateWidget(HomePageStack oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.index == oldWidget.index) return; + final fromRight = widget.index > oldWidget.index; + _slide = Tween(begin: Offset(fromRight ? _travel : -_travel, 0), end: Offset.zero).animate(_fade); + _controller.duration = OmiMotion.of(context).standard; + _controller.forward(from: 0); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // Every child keeps the same wrapper types whether or not it is selected, so switching + // never rebuilds a page's subtree; only the selected one is driven by the animation. + return IndexedStack( + index: widget.index, + children: [ + for (var i = 0; i < widget.children.length; i++) + FadeTransition( + opacity: i == widget.index ? _fade : kAlwaysCompleteAnimation, + child: SlideTransition( + position: i == widget.index ? _slide : const AlwaysStoppedAnimation(Offset.zero), + child: widget.children[i], + ), + ), + ], + ); + } +} diff --git a/app/lib/pages/home/widgets/home_tab_switcher.dart b/app/lib/pages/home/widgets/home_tab_switcher.dart index 89a02be1c8d..488511bc91e 100644 --- a/app/lib/pages/home/widgets/home_tab_switcher.dart +++ b/app/lib/pages/home/widgets/home_tab_switcher.dart @@ -3,12 +3,21 @@ import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:omi/backend/preferences.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/services/dev_controls/addressability_catalog.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/platform/platform_manager.dart'; +/// A dot on Tasks after onboarding saved the person's first to-dos, until they open Tasks. +abstract final class NewTasksDot { + static String _key(String uid) => 'home/newTasksDot/$uid'; + static bool get showing => SharedPreferencesUtil().getBool(_key(SharedPreferencesUtil().uid)); + static void show() => SharedPreferencesUtil().saveBool(_key(SharedPreferencesUtil().uid), true); + static void clear() => SharedPreferencesUtil().remove(_key(SharedPreferencesUtil().uid)); +} + /// Home and Tasks as two large words at the top of the shell; the selected one is in full ink, the /// other muted. It replaces the bottom tab bar. [trailing] holds the selected page's own actions. class HomeTabSwitcher extends StatelessWidget { @@ -32,7 +41,8 @@ class HomeTabSwitcher extends StatelessWidget { child: _tab(context, selected, HomeProvider.homeTab, l10n.home, 'Home', OmiKeys.homeTabHome), ), const SizedBox(width: OmiSpacing.sm), - _tab(context, selected, HomeProvider.tasksTab, l10n.tasks, 'Tasks', OmiKeys.homeTabTasks), + _tab(context, selected, HomeProvider.tasksTab, l10n.tasks, 'Tasks', OmiKeys.homeTabTasks, + dot: selected != HomeProvider.tasksTab && NewTasksDot.showing), const Spacer(), if (trailing != null) trailing!, ], @@ -41,9 +51,11 @@ class HomeTabSwitcher extends StatelessWidget { } /// [analyticsName] is the stable event name; [label] is what the reader sees and hears. - Widget _tab(BuildContext context, int selected, int index, String label, String analyticsName, Key key) { + Widget _tab(BuildContext context, int selected, int index, String label, String analyticsName, Key key, + {bool dot = false}) { final isSelected = selected == index; void select() { + if (index == HomeProvider.tasksTab) NewTasksDot.clear(); onTabTap(index, isSelected); primaryFocus?.unfocus(); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -73,7 +85,20 @@ class HomeTabSwitcher extends StatelessWidget { curve: Curves.easeOut, style: OmiType.title2.copyWith( fontWeight: FontWeight.w700, color: isSelected ? OmiColors.textPrimary : OmiColors.textTertiary), - child: Text(label), + child: dot + ? Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label), + Padding( + key: const Key('home_tasks_new_dot'), + padding: const EdgeInsets.only(left: 3, top: 4), + child: Icon(Icons.fiber_manual_record, color: OmiColors.danger, size: 9), + ), + ], + ) + : Text(label), ), ), ), diff --git a/app/lib/pages/memories/widgets/memory_item.dart b/app/lib/pages/memories/widgets/memory_item.dart index 324541f20ac..8db519e8eb9 100644 --- a/app/lib/pages/memories/widgets/memory_item.dart +++ b/app/lib/pages/memories/widgets/memory_item.dart @@ -9,7 +9,7 @@ import 'package:omi/backend/schema/memory.dart'; import 'package:omi/pages/conversation_detail/conversation_detail_provider.dart'; import 'package:omi/pages/conversation_detail/page.dart'; import 'package:omi/services/client_device_service.dart'; -import 'package:omi/pages/settings/usage_page.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/memories_provider.dart'; import 'package:omi/providers/usage_provider.dart'; @@ -56,7 +56,7 @@ class MemoryItem extends StatelessWidget { showMemoryQuickEditSheet(context, memory, provider, readOnly: true); return; } - routeToPage(context, const UsagePage()); + openUpgradePaywall(context, source: 'locked_memory'); return; } if (editable) { @@ -187,7 +187,7 @@ class MemoryItem extends StatelessWidget { child: memory.isLocked ? OmiLockedPreview( status: context.l10n.statusPending, - label: context.watch().showSubscriptionUI ? context.l10n.viewUsage : context.l10n.saved, + label: context.watch().showSubscriptionUI ? context.l10n.upgrade : context.l10n.saved, onPressed: openMemory, child: rowContent, ) diff --git a/app/lib/pages/onboarding/ai_consent_widget.dart b/app/lib/pages/onboarding/ai_consent_widget.dart index ab542ef98cf..47f44a550e7 100644 --- a/app/lib/pages/onboarding/ai_consent_widget.dart +++ b/app/lib/pages/onboarding/ai_consent_widget.dart @@ -5,7 +5,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:omi/pages/onboarding/widgets/onboarding_card.dart'; +import 'package:omi/pages/onboarding/variants/yinka/widgets/onboarding_card.dart'; import 'package:omi/providers/auth_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -41,50 +41,68 @@ class _AiConsentWidgetState extends State { super.dispose(); } + /// The third-party AI services that receive audio and transcripts. Brand names, not translated. + /// Keep in step with the privacy policy and the serving configuration (Apple 5.1.2(i): name who + /// receives the data). + static const _aiProviders = 'Deepgram & OpenAI'; + @override Widget build(BuildContext context) { - final linkStyle = OmiType.footnote.copyWith(color: OmiColors.textPrimary, decoration: TextDecoration.underline); - return OnboardingStep( - card: OnboardingCard( - crossAxisAlignment: CrossAxisAlignment.start, - content: [ - Semantics(header: true, child: Text(context.l10n.dataAndPrivacy, style: OmiType.title1)), - const SizedBox(height: OmiSpacing.md), - Text(context.l10n.consentDataMessage, style: OmiType.subhead.copyWith(height: 1.5)), - const SizedBox(height: OmiSpacing.md), - RichText( - text: TextSpan( - style: OmiType.footnote.copyWith(color: OmiColors.textSecondary, height: 1.4), - children: [ - TextSpan(text: context.l10n.yourDataIsProtected), - TextSpan(text: context.l10n.privacyPolicy, style: linkStyle, recognizer: _privacyRecognizer), - TextSpan(text: context.l10n.and), - TextSpan(text: context.l10n.termsOfService, style: linkStyle, recognizer: _termsRecognizer), - const TextSpan(text: '.'), - ], + final l10n = context.l10n; + final p = OnboardingHero.palette; + final body = OmiType.callout.copyWith(color: p.textSecondary, height: 1.45); + final fine = OmiType.caption.copyWith(color: p.textTertiary, height: 1.3); + final link = fine.copyWith(color: p.textSecondary, decoration: TextDecoration.underline); + return OnboardingHeroStep( + headline: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Semantics( + header: true, + child: Text( + l10n.dataAndPrivacy, + style: OmiType.title1.copyWith(color: p.textPrimary), + textAlign: TextAlign.center, ), ), - ], - footer: [ - const SizedBox(height: OmiSpacing.xl), - OmiButton( - key: const Key('ai_consent_agree'), - label: context.l10n.agreeAndContinue, - expand: true, - onPressed: () { - OmiHaptics.selection(); - widget.onAgree(); - }, + const SizedBox(height: OmiSpacing.xs), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 320), + child: Text(l10n.consentShortMessage, style: body, textAlign: TextAlign.center), ), - const SizedBox(height: OmiSpacing.xxs), - OmiButton.tertiary( - key: const Key('ai_consent_use_different_account'), - label: context.l10n.useDifferentAccount, - expand: true, - onPressed: widget.onUseDifferentAccount, + const SizedBox(height: OmiSpacing.xs), + Text( + l10n.consentAiProviders(_aiProviders), + key: const Key('ai_consent_providers'), + style: OmiType.footnote.copyWith(color: p.textTertiary), + textAlign: TextAlign.center, ), ], ), + buttons: [ + OnboardingHeroButton( + key: const Key('ai_consent_agree'), + label: l10n.continueButton, + onPressed: () { + OmiHaptics.selection(); + widget.onAgree(); + }, + ), + ], + // One quiet line where it fits; long translations wrap to a second line. + legal: Text.rich( + TextSpan( + style: fine, + children: [ + TextSpan(text: l10n.byContinuingYouAgree), + TextSpan(text: l10n.termsOfUse, style: link, recognizer: _termsRecognizer), + TextSpan(text: l10n.and), + TextSpan(text: l10n.privacyPolicy, style: link, recognizer: _privacyRecognizer), + const TextSpan(text: '.'), + ], + ), + textAlign: TextAlign.center, + ), ); } } diff --git a/app/lib/pages/onboarding/auth.dart b/app/lib/pages/onboarding/auth.dart index 30818162a41..b4affd4494a 100644 --- a/app/lib/pages/onboarding/auth.dart +++ b/app/lib/pages/onboarding/auth.dart @@ -6,7 +6,8 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; -import 'package:omi/pages/onboarding/widgets/onboarding_card.dart'; +import 'package:omi/gen/assets.gen.dart'; +import 'package:omi/pages/onboarding/variants/yinka/widgets/onboarding_card.dart'; import 'package:omi/providers/auth_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -25,152 +26,89 @@ class _AuthComponentState extends State { Widget build(BuildContext context) { return Consumer( builder: (context, provider, child) { - return OnboardingStep( - card: OnboardingCard( - padding: const EdgeInsets.fromLTRB(OmiSpacing.xxl, 26, OmiSpacing.xxl, OmiSpacing.xs), - content: [ - // Loading indicator or spacing - SizedBox( - height: 20, - child: provider.loading ? const Center(child: OmiSpinner(size: OmiSpinnerSize.small)) : null, + final p = OnboardingHero.palette; + final legalStyle = OmiType.caption.copyWith(color: p.textTertiary, height: 1.3); + return OnboardingHeroStep( + headline: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The omi wordmark is the headline; the promise sits under it, quieter. + Semantics( + header: true, + label: 'Omi', + child: Assets.images.logoTextWhite.image( + key: const Key('onboarding_wordmark'), + height: 56, + color: p.textPrimary, + ), ), - - // Title text + const SizedBox(height: OmiSpacing.sm), Text( context.l10n.speakTranscribeSummarize, - style: TextStyle( - color: OmiColors.textPrimary, - fontSize: 32, - fontWeight: FontWeight.bold, - height: 1.2, - fontFamily: 'Manrope', - ), + style: OmiType.title3.copyWith(color: p.textSecondary, fontWeight: FontWeight.w500), textAlign: TextAlign.center, ), - - const SizedBox(height: 32), - - // Sign in buttons - if (!provider.isLocalDevProfile && (Platform.isIOS || Platform.isAndroid)) ...[ - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () { - OmiHaptics.selection(); - provider.onAppleSignIn(widget.onSignIn); - }, - style: ElevatedButton.styleFrom( - backgroundColor: OmiColors.accent, - foregroundColor: OmiColors.onAccent, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const FaIcon(FontAwesomeIcons.apple, size: 24), - const SizedBox(width: 8), - Text( - context.l10n.signInWithApple, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Manrope', - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - // Google sign in button - if (!provider.isLocalDevProfile) - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () { - OmiHaptics.selection(); - provider.onGoogleSignIn(widget.onSignIn); - }, - style: ElevatedButton.styleFrom( - backgroundColor: OmiColors.accent, - foregroundColor: OmiColors.onAccent, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const FaIcon(FontAwesomeIcons.google, size: 20), - const SizedBox(width: 8), - Text( - context.l10n.signInWithGoogle, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, fontFamily: 'Manrope'), - ), - ], - ), - ), + SizedBox( + height: 28, + child: provider.loading ? const Center(child: OmiSpinner(size: OmiSpinnerSize.small)) : null, + ), + ], + ), + buttons: [ + if (!provider.isLocalDevProfile && (Platform.isIOS || Platform.isAndroid)) ...[ + OnboardingHeroButton( + label: context.l10n.signInWithApple, + icon: const FaIcon(FontAwesomeIcons.apple), + onPressed: () { + OmiHaptics.selection(); + provider.onAppleSignIn(widget.onSignIn); + }, + ), + const SizedBox(height: OmiSpacing.sm), + ], + if (!provider.isLocalDevProfile) + OnboardingHeroButton( + label: context.l10n.signInWithGoogle, + icon: const FaIcon(FontAwesomeIcons.google), + onPressed: () { + OmiHaptics.selection(); + provider.onGoogleSignIn(widget.onSignIn); + }, + ), + // Local development sign-in. Only rendered for a local_dev build: community builds cannot + // complete a real OAuth flow, because provider OAuth clients are bound to the official + // bundle id and a community build is signed with a suffixed one. Never shown in a + // production-family build. + if (provider.isLocalDevProfile) + OnboardingHeroButton( + label: 'Sign in (local dev)', // omi-ux-allow: hardcoded-text -- local_dev builds only + outlined: true, + onPressed: () { + OmiHaptics.selection(); + provider.onLocalDevSignIn(widget.onSignIn); + }, + ), + ], + legal: Text.rich( + TextSpan( + style: legalStyle, + children: [ + TextSpan(text: context.l10n.byContinuingAgree), + TextSpan( + text: context.l10n.privacyPolicy, + style: legalStyle.copyWith(color: p.textSecondary, decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer()..onTap = provider.openPrivacyPolicy, ), - - // Local development sign-in. Only rendered for a local_dev - // build: community builds cannot complete a real OAuth flow, - // because provider OAuth clients are bound to the official - // bundle id and a community build is signed with a suffixed - // one. Never shown in a production-family build. - if (provider.isLocalDevProfile) ...[ - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - height: 56, - child: OutlinedButton( - onPressed: () { - OmiHaptics.selection(); - provider.onLocalDevSignIn(widget.onSignIn); - }, - style: OutlinedButton.styleFrom( - foregroundColor: OmiColors.textPrimary, - side: BorderSide(color: OmiColors.textPrimary.withValues(alpha: 0.4)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - ), - child: const Text( - 'Sign in (local dev)', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, fontFamily: 'Manrope'), - ), - ), + const TextSpan(text: ' & '), + TextSpan( + text: context.l10n.termsOfUse, + style: legalStyle.copyWith(color: p.textSecondary, decoration: TextDecoration.underline), + recognizer: TapGestureRecognizer()..onTap = provider.openTermsOfService, ), + const TextSpan(text: '.'), ], - - const SizedBox(height: 24), - - // Privacy policy text (same as welcome page) - RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: OmiColors.textPrimary.withValues(alpha: 0.6), - fontSize: 11, - fontFamily: 'Manrope', - ), - children: [ - TextSpan(text: context.l10n.byContinuingAgree), - TextSpan( - text: context.l10n.privacyPolicy, - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = provider.openPrivacyPolicy, - ), - const TextSpan(text: ' & '), - TextSpan( - text: context.l10n.termsOfUse, - style: const TextStyle(decoration: TextDecoration.underline), - recognizer: TapGestureRecognizer()..onTap = provider.openTermsOfService, - ), - const TextSpan(text: '.'), - ], - ), - ), - ], + ), + textAlign: TextAlign.center, ), ); }, diff --git a/app/lib/pages/onboarding/device_selection.dart b/app/lib/pages/onboarding/device_selection.dart deleted file mode 100644 index 86fabe9d01a..00000000000 --- a/app/lib/pages/onboarding/device_selection.dart +++ /dev/null @@ -1,214 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:video_player/video_player.dart'; - -import 'package:omi/pages/onboarding/wrapper.dart'; -import 'package:omi/ui/omi_tokens.dart'; -import 'package:omi/utils/platform/platform_manager.dart'; -import 'package:omi/utils/l10n_extensions.dart'; -import 'package:omi/utils/logger.dart'; -import 'package:omi/utils/other/temp.dart'; - -class DeviceSelectionPage extends StatefulWidget { - const DeviceSelectionPage({super.key}); - - @override - State createState() => _DeviceSelectionPageState(); -} - -class _DeviceSelectionPageState extends State with SingleTickerProviderStateMixin { - late final AnimationController _controller; - late Animation _arrowAnimation; - late VideoPlayerController _videoController; - bool _isVideoInitialized = false; - double _videoProgress = 0.0; - - @override - void initState() { - super.initState(); - _controller = AnimationController(duration: const Duration(milliseconds: 600), vsync: this)..repeat(reverse: true); - - _arrowAnimation = Tween( - begin: 0, - end: 4, - ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); - - _initializeVideo(); - PlatformManager.instance.analytics.onboardingWelcomeShown(); - } - - void _initializeVideo() async { - _videoController = VideoPlayerController.asset('assets/images/onboarding.mp4'); - try { - await _videoController.initialize(); - setState(() { - _isVideoInitialized = true; - }); - _videoController.setLooping(true); - _videoController.play(); - - // Listen to video progress - _videoController.addListener(_updateVideoProgress); - } catch (e) { - Logger.debug('Error initializing video: $e'); - } - } - - void _updateVideoProgress() { - if (_videoController.value.isInitialized) { - final position = _videoController.value.position; - final duration = _videoController.value.duration; - if (duration.inMilliseconds > 0) { - setState(() { - _videoProgress = position.inMilliseconds / duration.inMilliseconds; - }); - } - } - } - - @override - void dispose() { - _controller.dispose(); - _videoController.removeListener(_updateVideoProgress); - _videoController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Material( - child: Scaffold( - backgroundColor: Colors.white, - body: Stack( - children: [ - // Main content - Column( - children: [ - // Video background area - constrained to available space - Expanded( - child: Container( - width: double.infinity, - margin: const EdgeInsets.only(top: 60, bottom: 20), - child: _isVideoInitialized - ? FittedBox( - fit: BoxFit.cover, - child: SizedBox( - width: _videoController.value.size.width, - height: _videoController.value.size.height, - child: VideoPlayer(_videoController), - ), - ) - : Container(color: Colors.white), - ), - ), - - // Bottom drawer card - wraps content - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(32, 24, 32, 20), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.only(topLeft: Radius.circular(40), topRight: Radius.circular(40)), - ), - child: SafeArea( - top: false, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Title and subtitle - Text( - context.l10n.omiYourAiCompanion, - style: const TextStyle( - color: Colors.black, - fontSize: 34, - fontWeight: FontWeight.bold, - height: 1.2, - fontFamily: 'Manrope', - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 12), - Text( - context.l10n.captureEveryMoment, - style: TextStyle( - color: Colors.black.withValues(alpha: 0.7), - fontSize: 16, - height: 1.4, - fontFamily: 'Manrope', - ), - textAlign: TextAlign.center, - ), - - const SizedBox(height: 20), - - // Continue button - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () async { - OmiHaptics.selection(); - PlatformManager.instance.analytics.onboardingGetStartedTapped(); - if (mounted) { - routeToPage(context, const OnboardingWrapper()); - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - context.l10n.getStarted, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - fontFamily: 'Manrope', - ), - ), - const SizedBox(width: 8), - AnimatedBuilder( - animation: _arrowAnimation, - builder: (context, child) { - return Transform.translate( - offset: Offset(_arrowAnimation.value, 0), - child: const Icon(Icons.arrow_forward, size: 20), - ); - }, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ], - ), - - // Video progress indicator in top right - if (_isVideoInitialized) - Positioned( - top: 60, - right: 20, - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - value: _videoProgress, - strokeWidth: 2, - backgroundColor: Colors.grey.withValues(alpha: 0.3), - valueColor: AlwaysStoppedAnimation(Colors.grey[700]!), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart b/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart index 80b22aad5a2..2eca183a7b5 100644 --- a/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart +++ b/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart @@ -8,6 +8,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/home/widgets/home_tab_switcher.dart' show NewTasksDot; import 'package:omi/pages/onboarding/variants/yinka/activation/activation_capture.dart'; import 'package:omi/pages/onboarding/variants/yinka/activation/activation_motion.dart'; import 'package:omi/pages/onboarding/variants/yinka/activation/activation_visuals.dart'; @@ -98,6 +99,8 @@ class _ActivationFlowState extends State { _track(next.name); // Each visit to the dictation listens afresh. if (next == ActivationStage.dictate) _capture = null; + // The to-dos are saved: Home marks Tasks with a dot until the person opens it. + if (next == ActivationStage.taskList) NewTasksDot.show(); setState(() => _stage = next); } diff --git a/app/lib/pages/onboarding/variants/yinka/widgets/onboarding_card.dart b/app/lib/pages/onboarding/variants/yinka/widgets/onboarding_card.dart index d3551cd2cc6..216366e96dc 100644 --- a/app/lib/pages/onboarding/variants/yinka/widgets/onboarding_card.dart +++ b/app/lib/pages/onboarding/variants/yinka/widgets/onboarding_card.dart @@ -1,8 +1,5 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:omi/env/env.dart'; -import 'package:omi/env/environment_profile.dart'; import 'package:omi/ui/omi_tokens.dart'; import 'package:omi/ui/ui.dart'; @@ -90,16 +87,12 @@ class OnboardingStep extends StatelessWidget { } } -/// How sign-in and the data & privacy step look: the Omi pendant on the light onboarding surface -/// (default), or the launch film full-screen under white type. The film is a design comparison for -/// now: `--dart-define=OMI_ONBOARDING_HERO=video`, honoured in debug and local_dev builds only. +/// How sign-in and the data & privacy step look: the launch film full-screen under white type. abstract final class OnboardingHero { - static const _flag = String.fromEnvironment('OMI_ONBOARDING_HERO'); + static const bool video = true; - static bool get video => (kDebugMode || Env.profile == AppEnvironmentProfile.localDev) && _flag == 'video'; - - /// Type and buttons: white over the film, the theme's own over the pendant. - static OmiPalette get palette => video ? OmiPalette.dark : OmiColors.active; + /// Type and buttons: white over the film. + static OmiPalette get palette => OmiPalette.dark; } /// Sign-in and data & privacy: the hero (pendant or film) above, [headline] centred in the space diff --git a/app/lib/pages/onboarding/widgets/onboarding_hero_film.dart b/app/lib/pages/onboarding/widgets/onboarding_hero_film.dart new file mode 100644 index 00000000000..239a01ececc --- /dev/null +++ b/app/lib/pages/onboarding/widgets/onboarding_hero_film.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'package:video_player/video_player.dart'; + +import 'package:omi/gen/assets.gen.dart'; +import 'package:omi/ui/ui.dart'; + +/// The launch film behind sign-in and data & privacy ([OnboardingHero.video]): full-screen, muted, +/// looping, darkened toward the bottom so the white type and buttons read. Under Reduce Motion it +/// holds its first frame. +class OnboardingHeroFilm extends StatefulWidget { + const OnboardingHeroFilm({super.key}); + + @override + State createState() => _OnboardingHeroFilmState(); +} + +class _OnboardingHeroFilmState extends State { + late final VideoPlayerController _film = VideoPlayerController.asset( + Assets.videos.onboardingLaunchLoop, + videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), + ); + bool _ready = false; + + @override + void initState() { + super.initState(); + _film.initialize().then((_) async { + if (!mounted) return; + await _film.setVolume(0); + await _film.setLooping(true); + if (!mounted) return; + if (!MediaQuery.disableAnimationsOf(context)) await _film.play(); + if (mounted) setState(() => _ready = true); + }).catchError((Object _) {}); + } + + @override + void dispose() { + _film.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ink = OmiPalette.light.textPrimary; + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: ColoredBox( + color: ink, + child: Stack( + fit: StackFit.expand, + children: [ + AnimatedOpacity( + opacity: _ready ? 1 : 0, + duration: const Duration(milliseconds: 600), + child: _ready + ? FittedBox( + fit: BoxFit.cover, + clipBehavior: Clip.hardEdge, + child: SizedBox( + width: _film.value.size.width, + height: _film.value.size.height, + child: VideoPlayer(_film), + ), + ) + : const SizedBox.shrink(), + ), + // A little shade under the status bar, and a deep one where the type and buttons sit. + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + ink.withValues(alpha: 0.35), + ink.withValues(alpha: 0.0), + ink.withValues(alpha: 0.0), + ink.withValues(alpha: 0.75), + ink.withValues(alpha: 0.92), + ], + stops: const [0.0, 0.18, 0.4, 0.68, 1.0], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/pages/onboarding/wrapper.dart b/app/lib/pages/onboarding/wrapper.dart index 68db725cf33..ad9559ccc08 100644 --- a/app/lib/pages/onboarding/wrapper.dart +++ b/app/lib/pages/onboarding/wrapper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:omi/services/onboarding_sync_runtime.dart'; import 'onboarding_step_telemetry.dart'; import 'package:omi/utils/platform/platform_manager.dart'; @@ -22,6 +24,7 @@ import 'package:omi/pages/onboarding/primary_language/primary_language_widget.da import 'package:omi/pages/onboarding/complete_screen.dart'; import 'package:omi/pages/onboarding/variants/yinka/yinka_onboarding_flow.dart'; import 'package:omi/pages/onboarding/web_onboarding_page.dart'; +import 'package:omi/pages/onboarding/widgets/onboarding_hero_film.dart'; import 'package:omi/pages/paywall/paywall_router.dart'; import 'package:omi/services/experiments/onboarding_variant_gate.dart'; import 'package:omi/services/experiments/paywall_experiment.dart'; @@ -39,6 +42,10 @@ import 'package:omi/utils/analytics/registry/events.g.dart'; import 'package:omi/utils/analytics/registry/typed_events.dart'; import 'package:omi/utils/analytics/product_telemetry.dart'; import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/home/widgets/home_tab_switcher.dart' show NewTasksDot; +import 'package:omi/backend/http/api/action_items.dart' show createActionItem, tryGetActionItems; +import 'package:omi/utils/logger.dart'; import 'package:omi/utils/other/temp.dart'; import 'package:omi/app_globals.dart'; import 'package:omi/core/app_shell.dart'; @@ -265,6 +272,7 @@ class _OnboardingWrapperState extends State with TickerProvid SharedPreferencesUtil().remove(_resumeKey); _stepTelemetry.exit(); _completeOnboardingTelemetry(); + unawaited(_addStarterTasks(context.l10n)); PlatformManager.instance.analytics.onboardingCompleted(); PaintingBinding.instance.imageCache.clear(); if (!paywallShown) await _maybeShowOnboardingPaywall(); @@ -273,11 +281,45 @@ class _OnboardingWrapperState extends State with TickerProvid return true; } - /// The experiment's end-of-onboarding placement: only arms whose payload asks for it, never - /// control, and never when subscription UI is hidden (the router checks that). + /// An empty Tasks tab teaches nothing: when onboarding produced no to-dos, add three starter tasks + /// that walk the person through Omi (once per account; never on a failed read, so nobody gets + /// them on top of real tasks). Every onboarding arm gets them, so the experiment stays fair. + static Future _addStarterTasks(AppLocalizations l10n) async { + final key = 'onboarding/starterTasks/${SharedPreferencesUtil().uid}'; + if (SharedPreferencesUtil().getBool(key)) return; + // The activation already showed real to-dos (they may still be saving): no starter tasks. + if (NewTasksDot.showing) { + SharedPreferencesUtil().saveBool(key, true); + return; + } + try { + final existing = await tryGetActionItems(limit: 1); + if (existing == null) return; + SharedPreferencesUtil().saveBool(key, true); + if (existing.actionItems.isNotEmpty) return; + for (final description in [ + l10n.onboardingStarterTaskAsk, + l10n.onboardingStarterTaskRecord, + l10n.onboardingStarterTaskCheck, + ]) { + await createActionItem(description: description); + } + NewTasksDot.show(); + } catch (e) { + Logger.debug('Starter tasks not added: $e'); + } + } + + /// The experiment's end-of-onboarding placement, when the payload turns it on: a variant shows its + /// paywall; control shows nothing but is exposed at the same moment, so both arms enter the + /// experiment at the same point. Never when subscription UI is hidden (the router checks that). Future _maybeShowOnboardingPaywall() async { final config = await PaywallExperiment.resolve(); - if (!mounted || config.isControl || !config.onboardingPaywall) return; + if (!mounted || !config.onboardingPaywall) return; + if (config.isControl) { + PaywallRouter.exposeControl(context, config: config); + return; + } await PaywallRouter.openVariant(context, source: 'onboarding', config: config); } @@ -675,7 +717,11 @@ class _OnboardingWrapperState extends State with TickerProvid backgroundColor: OmiColors.surface0, body: Stack( children: [ - if (index == kAuthPage || showBackground) _background(), + // Sign-in and consent (before any onboarding arm): the launch film behind white type. + if (index == kAuthPage || index == kAiConsentPage) + const OnboardingHeroFilm() + else if (showBackground) + _background(), OnboardingStepLayout( reserveHeader: index == kSpeechProfilePage, onBack: previous == null ? null : _goBack, diff --git a/app/lib/pages/paywall/paywall_router.dart b/app/lib/pages/paywall/paywall_router.dart index b9a553861e5..dbd497f8565 100644 --- a/app/lib/pages/paywall/paywall_router.dart +++ b/app/lib/pages/paywall/paywall_router.dart @@ -5,6 +5,7 @@ import 'package:omi/pages/paywall/outline_paywall_page.dart'; import 'package:omi/pages/paywall/web_paywall_page.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/services/experiments/paywall_experiment.dart'; +import 'package:omi/services/experiments/paywall_reask.dart'; import 'package:omi/utils/analytics/registry/events.g.dart'; import 'package:omi/utils/analytics/registry/typed_events.dart'; import 'package:omi/utils/other/temp.dart'; @@ -35,6 +36,7 @@ abstract final class PaywallRouter { // The reader is about to see a paywall: the variant page below, or the sheet the opener shows // right after a false return. Either way they count for their arm. PaywallExperiment.recordExposure(resolved, record: record); + PaywallReask.noteShown(); const TypedEvents().emit(PaywallVariantShown(variant: variantWire(resolved.variant), source: sourceWire(source))); if (resolved.isControl) return false; @@ -52,6 +54,22 @@ abstract final class PaywallRouter { return true; } + /// Control's side of a placement only variants have (the end of first-run onboarding): nothing + /// is shown, but an eligible control reader is exposed at the same moment a variant reader would + /// see a paywall, so both arms enter the experiment at the same point. True when exposed. + static bool exposeControl( + BuildContext context, { + required PaywallExperimentConfig config, + bool Function(String key, String variant)? record, + }) { + if (!config.isControl) return false; + final usage = context.read(); + if (!usage.showSubscriptionUI) return false; + if (usage.subscription?.subscription.plan.isPaid == true) return false; + PaywallExperiment.recordExposure(config, record: record); + return true; + } + static PaywallVariantShownVariant variantWire(PaywallVariant variant) => switch (variant) { PaywallVariant.control => PaywallVariantShownVariant.control, PaywallVariant.outline => PaywallVariantShownVariant.outline, diff --git a/app/lib/pages/settings/chat_apps/chat_apps_page.dart b/app/lib/pages/settings/chat_apps/chat_apps_page.dart index 90524ddc36d..335db310d68 100644 --- a/app/lib/pages/settings/chat_apps/chat_apps_page.dart +++ b/app/lib/pages/settings/chat_apps/chat_apps_page.dart @@ -97,7 +97,12 @@ class _ChatAppsPageState extends State { 'chat_apps', currentPlan: context.read().subscription?.subscription.plan.wireName ?? 'unknown', ); - routeToPage(context, const UsagePage(showUpgradeDialog: true, paywallSource: 'chat_apps')); + routeToPage( + context, + const UsagePage( + showUpgradeDialog: true, + paywallSource: 'chat_apps', + )); } Future _connect(ChatChannel channel) async { diff --git a/app/lib/pages/settings/settings_drawer.dart b/app/lib/pages/settings/settings_drawer.dart index 086c3cb6fd9..776aec640db 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -5,18 +5,21 @@ import 'package:provider/provider.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/pages/settings/settings_destinations.dart'; +import 'package:omi/models/subscription.dart'; import 'package:omi/pages/settings/settings_groups.dart'; -import 'package:omi/providers/usage_provider.dart'; import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart'; +import 'package:omi/pages/settings/widgets/settings_plan_section.dart'; +import 'package:omi/providers/usage_provider.dart'; import 'package:omi/pages/settings/settings_search_index.dart'; import 'package:omi/providers/device_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/platform/platform_service.dart'; -/// The Settings sheet: Account, then eight groups (Device, Recording & Transcription, Voice, -/// Notifications & Display, Integrations, Privacy & Data, Help & About, Developer Settings), plus -/// search over every row in Settings and its pages ([settingsSearchEntries]). +/// The Settings sheet: Account and Plan, then the groups people open most (Device, Recording & +/// Transcription, Notifications & Display, Data & Privacy), Voice, Integrations, Memories, Goals, +/// Help & About and Developer Settings, plus search over every row in Settings and its pages +/// ([settingsSearchEntries]). /// /// Every setting is at most one tap below the sheet: a group row opens its group page /// (settings_groups.dart), whose rows open the same pages the sheet used to open directly. @@ -30,12 +33,15 @@ class SettingsDrawer extends StatefulWidget { /// Opens Settings; resolves when the sheet closes (callers compare settings after that). static Future show(BuildContext context) { // Settings is a grouped list: surface1 rows on the page colour, so the sheet itself is surface0 - // (showOmiSheet paints surface1). Same shell otherwise: framework drag handle, own header with - // a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page + // (showOmiSheet paints surface1). Same shell otherwise, minus the handle band: its own header + // with a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page // opened here repaints the sheet too. return showOmiSurfaceSheet( context: context, surface: () => OmiColors.surface0, + // No handle band: the header (with its close X) is the top of the sheet, which still swipes + // down to close. + showDragHandle: false, builder: (context) => const FractionallySizedBox(heightFactor: 0.92, child: SettingsDrawer()), ); } @@ -88,7 +94,8 @@ class _SettingsDrawerState extends State { Widget _row( SettingsDestination destination, { required String key, - required FaIconData icon, + FaIconData? icon, + Widget? leading, required String title, String? subtitle, String? value, @@ -96,7 +103,7 @@ class _SettingsDrawerState extends State { }) { return OmiSettingsRow( key: ValueKey(key), - leading: FaIcon(icon), + leading: leading ?? FaIcon(icon), title: title, subtitle: subtitle, value: value, @@ -106,11 +113,24 @@ class _SettingsDrawerState extends State { ); } - /// Show the subscriber's actual tier on the Plan & Usage row. + /// The plan on the Plan & Usage row: "Free Plan" and the Plus plan name (their section adds an + /// Upgrade button), "Pro" for the other paid plans. String? _planValue(UsageProvider usage) { - final plan = usage.subscription?.subscription.plan; - if (plan == null || !plan.isPaid) return null; - return currentPlanDisplayName(context, usage.subscription); + final subscription = usage.subscription; + final plan = subscription?.subscription.plan; + if (plan == null) return null; + if (usage.showSubscriptionUI && (plan == PlanType.basic || plan == PlanType.plus)) { + return planDisplayName(plan, subscription!.availablePlans, context.l10n); + } + if (!plan.isPaid) return null; + return currentPlanDisplayName(context, subscription); + } + + /// The device on the Device row: its battery when it reports one, else Connected / Disconnected. + String _deviceValue(DeviceProvider device) { + if (!device.isConnected) return context.l10n.disconnected; + final battery = device.batteryLevel; + return battery >= 0 ? '$battery%' : context.l10n.connected; } Widget _buildSettings(BuildContext context) { @@ -127,33 +147,33 @@ class _SettingsDrawerState extends State { _row( SettingsDestination.profile, key: 'settings_account', - icon: FontAwesomeIcons.solidUser, + leading: _AccountInitial(name: name), title: name.isEmpty ? l10n.account : name, subtitle: email.isEmpty ? null : email, ), ], ), const SizedBox(height: OmiSpacing.xl), - // Plan, referrals and feedback stay one tap from the sheet (David, 2026-09-24). + // Plan stays one tap from the sheet (David, 2026-09-24); for Free and Plus it is a plan card + // with the minutes meter and the Upgrade button. OmiSettingsGroup( children: [ - _row( - SettingsDestination.planAndUsage, - key: 'settings_row_planAndUsage', - icon: FontAwesomeIcons.chartLine, - title: l10n.planAndUsage, - value: planValue, - ), - _row( - SettingsDestination.referral, - key: 'settings_row_referral', - icon: FontAwesomeIcons.gift, - title: l10n.referralProgram, - tag: SettingsTag(l10n.newTag, OmiColors.success), + SettingsPlanSection( + planName: planValue, + onOpen: () => _open(SettingsDestination.planAndUsage), + row: _row( + SettingsDestination.planAndUsage, + key: 'settings_row_planAndUsage', + icon: FontAwesomeIcons.chartLine, + title: l10n.planAndUsage, + value: planValue, + ), ), ], ), const SizedBox(height: OmiSpacing.xl), + // The groups people open most come first: Device and Recording & Transcription are most of + // the visits to Settings, then Notifications & Display and Data & Privacy. OmiSettingsGroup( children: [ _row( @@ -161,6 +181,7 @@ class _SettingsDrawerState extends State { key: 'settings_group_device', icon: FontAwesomeIcons.bluetooth, title: l10n.device, + value: _deviceValue(context.watch()), ), _row( SettingsDestination.recordingGroup, @@ -168,18 +189,29 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.microphone, title: l10n.recordingAndTranscription, ), - _row( - SettingsDestination.voice, - key: 'settings_group_voice', - icon: FontAwesomeIcons.volumeHigh, - title: l10n.assistantVoiceSettingsTitle, - ), _row( SettingsDestination.notificationsGroup, key: 'settings_group_notifications', icon: FontAwesomeIcons.solidBell, title: l10n.notificationsAndDisplay, ), + _row( + SettingsDestination.privacyGroup, + key: 'settings_group_privacy', + icon: FontAwesomeIcons.shield, + title: l10n.dataAndPrivacy, + ), + ], + ), + const SizedBox(height: OmiSpacing.xl), + OmiSettingsGroup( + children: [ + _row( + SettingsDestination.voice, + key: 'settings_group_voice', + icon: FontAwesomeIcons.volumeHigh, + title: l10n.assistantVoiceSettingsTitle, + ), _row( SettingsDestination.integrations, key: 'settings_group_integrations', @@ -193,12 +225,6 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.terminal, title: l10n.connectAnAgent, ), - _row( - SettingsDestination.privacyGroup, - key: 'settings_group_privacy', - icon: FontAwesomeIcons.shield, - title: l10n.dataAndPrivacy, - ), ], ), const SizedBox(height: OmiSpacing.xl), @@ -220,6 +246,7 @@ class _SettingsDrawerState extends State { ], ), const SizedBox(height: OmiSpacing.xl), + // Referrals and feedback stay one tap from the sheet (David, 2026-09-24). OmiSettingsGroup( children: [ _row( @@ -228,6 +255,12 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.circleQuestion, title: l10n.helpAndAbout, ), + _row( + SettingsDestination.referral, + key: 'settings_row_referral', + icon: FontAwesomeIcons.gift, + title: l10n.referralProgram, + ), if (PlatformService.isIntercomSupported) _row( SettingsDestination.feedback, @@ -324,19 +357,47 @@ class _SettingsDrawerState extends State { @override Widget build(BuildContext context) { final motion = OmiMotion.of(context); - return Column( - children: [ - AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)), - const SizedBox(height: OmiSpacing.xs), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: - _isSearching && _searchQuery.trim().isNotEmpty ? _buildSearchResults(context) : _buildSettings(context), + return Padding( + padding: const EdgeInsets.only(top: OmiSpacing.sm), + child: Column( + children: [ + AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)), + const SizedBox(height: OmiSpacing.xs), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: _isSearching && _searchQuery.trim().isNotEmpty + ? _buildSearchResults(context) + : _buildSettings(context), + ), ), - ), - ], + ], + ), + ); + } +} + +/// The signed-in person's initial in a 32 pt circle (an avatar fits a settings row's leading slot); +/// a person glyph when there is no name yet. +class _AccountInitial extends StatelessWidget { + const _AccountInitial({required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + final initial = name.trim().isEmpty ? null : name.trim().characters.first.toUpperCase(); + return ExcludeSemantics( + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + decoration: BoxDecoration(color: OmiColors.surface3, shape: BoxShape.circle), + child: initial == null + ? const FaIcon(FontAwesomeIcons.solidUser, size: 14) + : Text(initial, style: OmiType.headline.copyWith(fontSize: 15)), + ), ); } } diff --git a/app/lib/pages/settings/transcription_settings_page.dart b/app/lib/pages/settings/transcription_settings_page.dart index 37be3b6fdff..1d136005ea7 100644 --- a/app/lib/pages/settings/transcription_settings_page.dart +++ b/app/lib/pages/settings/transcription_settings_page.dart @@ -887,7 +887,10 @@ class _TranscriptionSettingsPageState extends State { PlatformManager.instance.analytics.paywallOpened('transcription_settings', currentPlan: plan); routeToPage( context, - const UsagePage(showUpgradeDialog: true, paywallSource: 'transcription_settings'), + const UsagePage( + showUpgradeDialog: true, + paywallSource: 'transcription_settings', + ), ); }, child: ConstrainedBox( diff --git a/app/lib/pages/settings/usage_page.dart b/app/lib/pages/settings/usage_page.dart index a3d3785c426..191d1866494 100644 --- a/app/lib/pages/settings/usage_page.dart +++ b/app/lib/pages/settings/usage_page.dart @@ -21,14 +21,13 @@ import 'package:omi/models/subscription.dart'; import 'package:omi/models/user_usage.dart'; import 'package:omi/pages/settings/fair_use_page.dart'; import 'package:omi/pages/settings/transcription_settings_page.dart'; -import 'package:omi/pages/paywall/paywall_router.dart'; -import 'package:omi/pages/settings/widgets/plans_sheet.dart'; import 'package:omi/pages/settings/widgets/plans/contextual_plans.dart'; import 'package:omi/pages/settings/widgets/usage/usage_chart.dart'; import 'package:omi/pages/settings/widgets/usage/usage_stat_tile.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart'; import 'package:omi/services/wals/sync_rate_limit_reconciliation.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/other/temp.dart'; @@ -36,9 +35,10 @@ import 'package:omi/utils/other/temp.dart'; class UsagePage extends StatefulWidget { final bool showUpgradeDialog; - /// Surface id used when [showUpgradeDialog] opens the plans sheet. + /// Surface id used when [showUpgradeDialog] opens the offer on arrival. /// The usage-page upgrade button always reports `usage_page`. final String paywallSource; + @visibleForTesting final bool debugSkipFetch; @visibleForTesting @@ -68,10 +68,6 @@ class _UsagePageState extends State with TickerProviderStateMixin, Wi final List _screenshotKeys = List.generate(4, (_) => GlobalKey()); final GlobalKey _shareButtonKey = GlobalKey(); UsageMetric _selectedMetric = UsageMetric.words; - late AnimationController _waveController; - late AnimationController _notesController; - late AnimationController _arrowController; - late Animation _arrowAnimation; String selectedPlan = 'yearly'; // 'yearly' or 'monthly' Map? _fairUseStatus; @@ -319,17 +315,6 @@ class _UsagePageState extends State with TickerProviderStateMixin, Wi WidgetsBinding.instance.addObserver(this); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(_handleTabSelection); - _waveController = AnimationController(duration: const Duration(milliseconds: 18000), vsync: this)..repeat(); - _notesController = AnimationController(duration: const Duration(milliseconds: 36000), vsync: this)..repeat(); - - _arrowController = AnimationController(duration: const Duration(milliseconds: 600), vsync: this) - ..repeat(reverse: true); - - _arrowAnimation = Tween( - begin: 0, - end: 4, - ).animate(CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut)); - WidgetsBinding.instance.addPostFrameCallback((_) { if (widget.debugSkipFetch) return; context.read().fetchUsageStats(period: 'today'); @@ -348,9 +333,6 @@ class _UsagePageState extends State with TickerProviderStateMixin, Wi WidgetsBinding.instance.removeObserver(this); _tabController.removeListener(_handleTabSelection); _tabController.dispose(); - _waveController.dispose(); - _notesController.dispose(); - _arrowController.dispose(); super.dispose(); } @@ -548,31 +530,17 @@ class _UsagePageState extends State with TickerProviderStateMixin, Wi ); } - Future _showPlansSheet({required String source, required bool trackOpen}) async { - if (!context.read().showSubscriptionUI) { - return; - } - if (!widget.forceNativePaywall && await PaywallRouter.openVariant(context, source: source, trackOpen: trackOpen)) { - return; - } - if (!mounted) return; - if (trackOpen) { - final plan = context.read().subscription?.subscription.plan.wireName ?? 'unknown'; - PlatformManager.instance.analytics.paywallOpened(source, currentPlan: plan); - } - final title = await plansSheetTitle(context); - if (!mounted || !context.read().showSubscriptionUI) return; - showOmiSheet( - context: context, - title: title, - padding: EdgeInsets.zero, - builder: (context) => PlansSheet( - waveController: _waveController, - notesController: _notesController, - arrowController: _arrowController, - arrowAnimation: _arrowAnimation, - analyticsSource: source, - ), + void _showPlansSheet({ + required String source, + required bool trackOpen, + }) { + final usage = context.read(); + if (!usage.showSubscriptionUI) return; + openUpgradePaywall( + context, + source: source, + trackOpen: trackOpen, + forceNative: widget.forceNativePaywall, ); } diff --git a/app/lib/pages/settings/widgets/settings_plan_section.dart b/app/lib/pages/settings/widgets/settings_plan_section.dart new file mode 100644 index 00000000000..11d737607cd --- /dev/null +++ b/app/lib/pages/settings/widgets/settings_plan_section.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/settings_plan_card_experiment.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +/// Plan & Usage on the Settings sheet. Every plan but Free and Plus (and no subscription yet) keeps +/// the plain row. Free and Plus get one of two layouts from the `settings-plan-card` A/B +/// ([SettingsPlanCardExperiment]): control keeps the row with the minutes meter and a full-width +/// Upgrade under it; `card` is a plan card with the plan name large, the meter, then a full-width +/// Upgrade, and tapping the card outside the button opens the Plan & Usage page. +class SettingsPlanSection extends StatefulWidget { + const SettingsPlanSection({super.key, required this.row, required this.onOpen, this.planName, this.arm}); + + /// The plain Plan & Usage row, shown when there is nothing to upgrade to. + final Widget row; + + /// Opens the Plan & Usage page. + final VoidCallback onOpen; + + /// The plan's display name ("Free", the Plus plan's name). + final String? planName; + + /// Forces an arm (tests); null reads the experiment. + final SettingsPlanCardArm? arm; + + @override + State createState() => _SettingsPlanSectionState(); +} + +class _SettingsPlanSectionState extends State { + SettingsPlanCardArm? _arm; + bool _exposed = false; + + @override + void initState() { + super.initState(); + _arm = widget.arm ?? SettingsPlanCardExperiment.cached; + if (widget.arm == null) { + SettingsPlanCardExperiment.resolve().then((arm) { + if (mounted && arm != _arm) setState(() => _arm = arm); + }); + } + } + + /// Counts the reader once the section is drawn for Free or Plus with the arm PostHog assigned. + void _expose(SettingsPlanCardArm arm) { + if (_exposed || widget.arm != null) return; + _exposed = true; + WidgetsBinding.instance.addPostFrameCallback((_) => SettingsPlanCardExperiment.recordExposure(arm)); + } + + @override + Widget build(BuildContext context) { + final usage = context.watch(); + final subscription = usage.subscription; + final plan = subscription?.subscription.plan; + if (subscription == null || !usage.showSubscriptionUI || (plan != PlanType.basic && plan != PlanType.plus)) { + return widget.row; + } + final arm = _arm; + if (arm == null) return widget.row; + _expose(arm); + final upgrade = OmiButton( + key: const ValueKey('settings_plan_upgrade'), + label: plan == PlanType.plus ? context.l10n.upgradeToUnlimited : context.l10n.upgrade, + expand: true, + onPressed: () => openSettingsUpgrade(context, plan!), + ); + if (arm == SettingsPlanCardArm.control) { + return _ControlSection(row: widget.row, subscription: subscription, upgrade: upgrade); + } + return _PlanCard(planName: widget.planName, onOpen: widget.onOpen, subscription: subscription, upgrade: upgrade); + } +} + +/// Control: the Plan & Usage row, then the minutes meter and a full-width Upgrade under it. +class _ControlSection extends StatelessWidget { + const _ControlSection({required this.row, required this.subscription, required this.upgrade}); + + final Widget row; + final UserSubscriptionResponse subscription; + final Widget upgrade; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hasLimit = subscription.transcriptionSecondsLimit > 0; + final used = (subscription.transcriptionSecondsUsed / 60).round(); + final limit = (subscription.transcriptionSecondsLimit / 60).round(); + final ratio = hasLimit + ? (subscription.transcriptionSecondsUsed / subscription.transcriptionSecondsLimit).clamp(0.0, 1.0) + : 0.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + row, + Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, 0, OmiSpacing.lg, OmiSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasLimit) ...[ + Text( + l10n.minsUsedThisMonth(NumberFormat.decimalPattern(l10n.localeName).format(used), limit), + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + const SizedBox(height: OmiSpacing.xs), + LinearProgressIndicator( + value: ratio, + backgroundColor: OmiColors.surface3, + valueColor: AlwaysStoppedAnimation(ratio >= .8 ? OmiColors.warning : OmiColors.accent), + minHeight: 4, + borderRadius: OmiRadius.pillAll, + ), + const SizedBox(height: OmiSpacing.md), + ], + upgrade, + ], + ), + ), + ], + ); + } +} + +/// Test arm: a plan card with the plan name large, the minutes meter, then a full-width Upgrade. +class _PlanCard extends StatelessWidget { + const _PlanCard({required this.planName, required this.onOpen, required this.subscription, required this.upgrade}); + + final String? planName; + final VoidCallback onOpen; + final UserSubscriptionResponse subscription; + final Widget upgrade; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hasLimit = subscription.transcriptionSecondsLimit > 0; + final used = (subscription.transcriptionSecondsUsed / 60).round(); + final limit = (subscription.transcriptionSecondsLimit / 60).round(); + final ratio = hasLimit + ? (subscription.transcriptionSecondsUsed / subscription.transcriptionSecondsLimit).clamp(0.0, 1.0) + : 0.0; + return Semantics( + key: const ValueKey('settings_row_planAndUsage'), + button: true, + label: l10n.planAndUsage, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, OmiSpacing.md, OmiSpacing.md, OmiSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Text( + l10n.planAndUsage, + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + ), + const SizedBox(height: 2), + if (planName != null) Text(planName!, style: OmiType.title2), + ], + ), + ), + ExcludeSemantics(child: Icon(Icons.chevron_right, size: 20, color: OmiColors.textTertiary)), + ], + ), + if (hasLimit) ...[ + const SizedBox(height: OmiSpacing.md), + LinearProgressIndicator( + value: ratio, + backgroundColor: OmiColors.surface3, + valueColor: AlwaysStoppedAnimation(ratio >= .8 ? OmiColors.warning : OmiColors.accent), + minHeight: 6, + borderRadius: OmiRadius.pillAll, + ), + const SizedBox(height: OmiSpacing.xs), + Text( + l10n.minsUsedThisMonth(NumberFormat.decimalPattern(l10n.localeName).format(used), limit), + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + ], + const SizedBox(height: OmiSpacing.lg), + upgrade, + ], + ), + ), + ), + ); + } +} + +/// The Settings Upgrade button: the paywall experiment's arm for the reader, else the plans sheet. +Future openSettingsUpgrade(BuildContext context, PlanType plan) => openUpgradePaywall( + context, + source: 'usage_page', + ); diff --git a/app/lib/pages/settings/widgets/standalone_plans_sheet.dart b/app/lib/pages/settings/widgets/standalone_plans_sheet.dart new file mode 100644 index 00000000000..6a53edd7ac3 --- /dev/null +++ b/app/lib/pages/settings/widgets/standalone_plans_sheet.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/pages/settings/widgets/plans_sheet.dart'; +import 'package:omi/ui/ui.dart'; + +/// [PlansSheet] with its own animation controllers, for callers that open the sheet without +/// hosting the Plan & Usage page's animations. +class StandalonePlansSheet extends StatefulWidget { + const StandalonePlansSheet({super.key, this.analyticsSource = 'usage_page'}); + + final String analyticsSource; + + @override + State createState() => _StandalonePlansSheetState(); +} + +class _StandalonePlansSheetState extends State with TickerProviderStateMixin { + late final AnimationController _waveController = + AnimationController(duration: const Duration(milliseconds: 18000), vsync: this)..repeat(); + late final AnimationController _arrowController = + AnimationController(duration: const Duration(milliseconds: 800), vsync: this)..repeat(reverse: true); + late final Animation _arrowAnimation = + Tween(begin: 0, end: 3).animate(CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut)); + + @override + void dispose() { + _waveController.dispose(); + _arrowController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PlansSheet( + waveController: _waveController, + notesController: _waveController, + arrowController: _arrowController, + arrowAnimation: _arrowAnimation, + analyticsSource: widget.analyticsSource, + ); + } +} + +/// Opens the plans sheet over [context]. +Future showStandalonePlansSheet(BuildContext context, {String analyticsSource = 'usage_page'}) async { + final title = await plansSheetTitle(context); + if (!context.mounted) return; + return showOmiSheet( + context: context, + title: title, + padding: EdgeInsets.zero, + builder: (_) => StandalonePlansSheet(analyticsSource: analyticsSource), + ); +} diff --git a/app/lib/pages/settings/widgets/upgrade_paywall.dart b/app/lib/pages/settings/widgets/upgrade_paywall.dart new file mode 100644 index 00000000000..803a4d27bea --- /dev/null +++ b/app/lib/pages/settings/widgets/upgrade_paywall.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:omi/pages/paywall/paywall_router.dart'; +import 'package:omi/pages/settings/widgets/standalone_plans_sheet.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/paywall_experiment.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; + +/// Shows the upgrade offer in place and logs `Paywall Opened` with [source] (a `PaywallOpenedSource` +/// wire name) once. [trackOpen] is false when the opener already logged it. +/// +/// The paywall experiment ([PaywallRouter]) picks the reader's arm; control, paid readers and any +/// failure get the existing plans sheet over the current screen. [forceNative] skips the router and +/// opens the sheet itself: the web variant's fallback, so a dead page never loops back into itself. +Future openUpgradePaywall( + BuildContext context, { + required String source, + bool trackOpen = true, + bool forceNative = false, + @visibleForTesting PaywallExperimentConfig? experiment, +}) async { + void logOpen() { + if (!trackOpen) return; + trackOpen = false; + final plan = context.read().subscription?.subscription.plan.wireName ?? 'unknown'; + PlatformManager.instance.analytics.paywallOpened(source, currentPlan: plan); + } + + if (!forceNative) { + // The router logs `Paywall Opened` itself when it shows a variant. + if (await PaywallRouter.openVariant(context, source: source, trackOpen: trackOpen, config: experiment)) return; + if (!context.mounted) return; + } + logOpen(); + await showStandalonePlansSheet(context, analyticsSource: source); +} diff --git a/app/lib/providers/action_items_provider.dart b/app/lib/providers/action_items_provider.dart index 52c78d5f48c..18c060eae7b 100644 --- a/app/lib/providers/action_items_provider.dart +++ b/app/lib/providers/action_items_provider.dart @@ -90,6 +90,9 @@ class ActionItemsProvider extends ChangeNotifier { // UI filter: show completed tasks view bool _showCompletedView = false; + // UI layout: group the list by project instead of by due date (offered once projects exist) + bool _groupByProject = false; + // Date range filter DateTime? _startDate; DateTime? _endDate; @@ -136,6 +139,7 @@ class ActionItemsProvider extends ChangeNotifier { bool get hasMore => _hasMore; bool get includeCompleted => _includeCompleted; bool get showCompletedView => _showCompletedView; + bool get groupByProject => _groupByProject; DateTime? get startDate => _startDate; DateTime? get endDate => _endDate; bool get hasActiveFilter => _startDate != null || _endDate != null; @@ -1043,6 +1047,11 @@ class ActionItemsProvider extends ChangeNotifier { notifyListeners(); } + void toggleGroupByProject() { + _groupByProject = !_groupByProject; + notifyListeners(); + } + void setDateRangeFilter(DateTime? startDate, DateTime? endDate) { _startDate = startDate; _endDate = endDate; diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index 44e73af8bae..c0a3a446e8d 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -103,6 +103,17 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption DateTime? _lastBatteryNotifyTime; bool _hasLowBatteryAlerted = false; bool _havingNewFirmware = false; + + /// A firmware update was found during first-run onboarding; Home shows the prompt once it opens. + bool _firmwarePromptDeferred = false; + + /// Shows a firmware update prompt that was held back during first-run onboarding. + void showDeferredFirmwareUpdatePrompt(BuildContext context) { + if (!_firmwarePromptDeferred) return; + _firmwarePromptDeferred = false; + if (_havingNewFirmware && context.mounted) showFirmwareUpdateDialog(context); + } + bool get havingNewFirmware => _havingNewFirmware && pairedDevice != null && isConnected && _allowsFirmwareUpdateForPairedDevice; @@ -1295,6 +1306,11 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption // Use a small delay to ensure the UI is ready Future.delayed(const Duration(milliseconds: 500), () { if (!_isCurrent(generation) || !_isCurrentFirmwareCheckSession(checkSession)) return; + // A first-run reader is mid-onboarding: hold the prompt until Home is up. + if (!SharedPreferencesUtil().onboardingCompleted) { + _firmwarePromptDeferred = true; + return; + } final context = globalNavigatorKey.currentContext; if (context != null && context.mounted) { showFirmwareUpdateDialog(context); diff --git a/app/lib/services/experiments/paywall_experiment.dart b/app/lib/services/experiments/paywall_experiment.dart index 87a7e94e8fb..480ef7ebeb9 100644 --- a/app/lib/services/experiments/paywall_experiment.dart +++ b/app/lib/services/experiments/paywall_experiment.dart @@ -42,8 +42,9 @@ class PaywallExperimentConfig { /// Only for [PaywallVariant.web]: the https page to render. final Uri? webUrl; - /// Whether this arm also shows its paywall once at the end of first-run onboarding (a placement - /// the control arm does not have). + /// Whether the end-of-first-run-onboarding placement is on for this arm. A variant shows its + /// paywall there; control shows nothing but is exposed at the same moment, so both arms enter the + /// experiment at the same point. Set it to the same value on every arm. final bool onboardingPaywall; bool get isControl => variant == PaywallVariant.control; @@ -71,7 +72,7 @@ class PaywallExperimentConfig { /// /// Payload per variant (JSON object): /// `{"url": "https://…", "onboarding_paywall": true}` — `url` is required for `web` and must be -/// https; `onboarding_paywall` is optional and defaults to false. +/// https; `onboarding_paywall` is optional, defaults to false, and is read for control too. abstract final class PaywallExperiment { /// PostHog flag key. Registered in `config/feature-flags.yaml`. static const enabledFlag = 'paywall-variant'; @@ -92,7 +93,7 @@ abstract final class PaywallExperiment { /// Parses one flag read into a config. Anything unexpected collapses to control. static PaywallExperimentConfig parse({String? variant, Object? payload}) { final arm = PaywallVariant.fromWire(variant); - if (arm == null || arm == PaywallVariant.control) return PaywallExperimentConfig.control; + if (arm == null) return PaywallExperimentConfig.control; Object? value = payload; if (value is String) { @@ -106,6 +107,10 @@ abstract final class PaywallExperiment { final onboardingPaywall = map['onboarding_paywall'] == true; switch (arm) { + case PaywallVariant.control: + return onboardingPaywall + ? const PaywallExperimentConfig(variant: PaywallVariant.control, onboardingPaywall: true) + : PaywallExperimentConfig.control; case PaywallVariant.outline: return PaywallExperimentConfig(variant: arm, onboardingPaywall: onboardingPaywall); case PaywallVariant.web: @@ -113,8 +118,6 @@ abstract final class PaywallExperiment { final url = raw is String ? Uri.tryParse(raw.trim()) : null; if (url == null || url.scheme != 'https' || url.host.isEmpty) return PaywallExperimentConfig.control; return PaywallExperimentConfig(variant: arm, webUrl: url, onboardingPaywall: onboardingPaywall); - case PaywallVariant.control: - return PaywallExperimentConfig.control; } } @@ -142,9 +145,7 @@ abstract final class PaywallExperiment { try { final variant = await (readVariant ?? AnalyticsManager().getFeatureFlagVariant)(enabledFlag); if (variant == null) return PaywallExperimentConfig.control; // unresolved: try again next time - final payload = variant == PaywallVariant.control.wireName - ? null - : await (readPayload ?? AnalyticsManager().getFeatureFlagPayload)(enabledFlag); + final payload = await (readPayload ?? AnalyticsManager().getFeatureFlagPayload)(enabledFlag); if (whoAmI() != who) return PaywallExperimentConfig.control; // account switched mid-read final config = parse(variant: variant, payload: payload); _cached = config; diff --git a/app/lib/services/experiments/paywall_reask.dart b/app/lib/services/experiments/paywall_reask.dart new file mode 100644 index 00000000000..595d10d40ce --- /dev/null +++ b/app/lib/services/experiments/paywall_reask.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/utils/analytics/analytics_manager.dart'; + +/// Re-offers the plans to free users when they open the app (A/B, flag `paywall-reask`). +/// +/// Readers who don't pay keep opening Omi but rarely meet a paywall, because every other paywall +/// waits for a limit. The re-ask comes back on app open, spaced out so it never nags: 2, 3, 5 +/// and then every 7 days, counted from the last paywall the reader saw anywhere (so a limit or a +/// Settings upgrade restarts the clock) and, for someone who never saw one, from their first Home +/// visit (never on onboarding day). +/// +/// Each due moment is an exposure for both arms (control is the denominator); control advances the +/// same schedule without showing anything, so both arms are counted at the same moments. +/// Fail-closed: no identity, an unread flag or any error shows nothing and exposes no one. +/// Pre-registration: `app/lib/services/experiments/paywall_reask.md`. +abstract final class PaywallReask { + /// PostHog flag key (project 302298). Registered in `config/feature-flags.yaml`. + static const enabledFlag = 'paywall-reask'; + + /// [PromptQueue] id for the queued re-ask. + static const promptId = 'paywall-reask'; + + /// Days between re-asks; the last gap repeats. + static const gapsInDays = [2, 3, 5, 7]; + + /// Variants of the flag that show the re-ask; `control` and anything else show nothing. + static const treatmentVariants = {'test', 'treatment', 'reask'}; + + /// Debug override: `--dart-define=OMI_PAYWALL_REASK=test` (or `=control`). With + /// `OMI_PAYWALL_REASK_NOW=true` the re-ask is due on every launch, for trying it on a simulator. + static const _debugVariant = String.fromEnvironment('OMI_PAYWALL_REASK'); + static const _debugAlwaysDue = bool.fromEnvironment('OMI_PAYWALL_REASK_NOW'); + + static String _lastShownKey(String uid) => 'paywall/lastShownAt/$uid'; + static String _countKey(String uid) => 'paywall/reaskCount/$uid'; + + /// The gap before the next re-ask after [reasksSoFar] of them. + static int gapAfter(int reasksSoFar) => gapsInDays[reasksSoFar.clamp(0, gapsInDays.length - 1)]; + + /// Whole local calendar days from [from] to [to]. + static int daysBetween(DateTime from, DateTime to) => + (DateTime(to.year, to.month, to.day).difference(DateTime(from.year, from.month, from.day)).inHours / 24).round(); + + /// True when a re-ask is due: at least the scheduled gap since the last paywall. + static bool isDue({required DateTime now, required DateTime lastShown, required int reasksSoFar}) => + daysBetween(lastShown, now) >= gapAfter(reasksSoFar); + + /// A paywall was shown (any source): restarts the clock. + static void noteShown({DateTime? now}) { + final uid = SharedPreferencesUtil().uid; + if (uid.isEmpty) return; + SharedPreferencesUtil().saveInt(_lastShownKey(uid), (now ?? DateTime.now()).millisecondsSinceEpoch); + } + + /// Called on Home open for a free reader. Returns true when the treatment arm should show the + /// re-ask now; records the exposure and advances the schedule for both arms. [readVariant] and + /// [recordExposure] default to the analytics manager and are injectable for tests. + static Future checkDue({ + DateTime? now, + Future Function(String key)? readVariant, + bool Function(String key, String variant)? recordExposure, + }) async { + final prefs = SharedPreferencesUtil(); + final uid = prefs.uid; + if (uid.isEmpty) return false; + final at = now ?? DateTime.now(); + final lastMs = prefs.getInt(_lastShownKey(uid), defaultValue: 0); + if (lastMs == 0) { + // First Home visit without any paywall yet: start the clock, never ask today. + noteShown(now: at); + if (!(kDebugMode && _debugAlwaysDue)) return false; + } + final count = prefs.getInt(_countKey(uid), defaultValue: 0); + final last = DateTime.fromMillisecondsSinceEpoch(lastMs == 0 ? at.millisecondsSinceEpoch : lastMs); + if (!(kDebugMode && _debugAlwaysDue) && !isDue(now: at, lastShown: last, reasksSoFar: count)) return false; + + final String? variant; + if (kDebugMode && _debugVariant.isNotEmpty) { + variant = _debugVariant; + } else { + try { + variant = await (readVariant ?? AnalyticsManager().getFeatureFlagVariant)(enabledFlag); + } catch (_) { + return false; + } + } + if (variant == null || variant.trim().isEmpty) return false; // not in the experiment + (recordExposure ?? AnalyticsManager().recordFeatureFlagExposure)(enabledFlag, variant.trim()); + prefs.saveInt(_countKey(uid), count + 1); + noteShown(now: at); + return treatmentVariants.contains(variant.trim().toLowerCase()); + } +} diff --git a/app/lib/services/experiments/paywall_reask.md b/app/lib/services/experiments/paywall_reask.md new file mode 100644 index 00000000000..55afe4980b6 --- /dev/null +++ b/app/lib/services/experiments/paywall_reask.md @@ -0,0 +1,36 @@ +# Pre-registration: paywall re-ask on app open (`paywall-reask`) + +**Question.** Free readers keep opening Omi but rarely meet a paywall, because every paywall +today waits for a limit, a locked item or a Settings tap. Does offering the plans again on app +open, spaced out so it doesn't nag, raise free→paid conversion without driving readers away? + +**Arms** (PostHog flag `paywall-reask`, experiment with variants `control` / `test`, 50/50, +user-based): +- `control` — nothing new. +- `test` — on app open (launch or back from the background), the paywall the reader would get + anywhere else (`openUpgradePaywall`, so `paywall-variant` still picks its design), queued in + `PromptQueue` (never over a recording, a call or another prompt). + +**Schedule** (`PaywallReask`). Due when 2, 3, 5 and then every 7 local calendar days have +passed since the last paywall the reader saw from any source; a reader who never saw one starts +the clock at their first Home visit, so never on onboarding day. At most one per day. Free plan +only (`basic`, subscription UI shown); dropped if the reader subscribed before it shows. + +**Unit.** A free reader with a due moment. Exposure = `$feature_flag_called` with +`$feature_flag = paywall-reask`, once per reader and arm, at the first due moment; control +advances the same schedule without showing anything, so both arms are counted alike. + +**Primary metric.** `Upgrade Succeeded` per exposed reader within 14 days of exposure. + +**Secondary / guardrails.** `Paywall Opened` (source `app_open_reask`) → `Upgrade Succeeded`; +`Upgrade Modal Dismissed` per reader; days with an app open in the 14 days after exposure (must +not drop more than 5% relative against control); `Product Error` per reader. + +**Decision rule.** Ship to 100% if the primary metric improves at PostHog's default confidence +and the active-days guardrail holds; stop early if active days drop more than 10% relative. +Run until each arm has at least 1,000 exposed readers or 21 days, whichever is later. Tuning the +gaps is a follow-up experiment, not a change mid-run. + +**Kill switch.** Set the split to control 100% (or deactivate the flag); no release needed. + +**Owner.** yinka. Created 2026-10-14. Review by 2026-11-13. diff --git a/app/lib/services/experiments/settings_plan_card_experiment.dart b/app/lib/services/experiments/settings_plan_card_experiment.dart new file mode 100644 index 00000000000..7aee31fcad5 --- /dev/null +++ b/app/lib/services/experiments/settings_plan_card_experiment.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; + +import 'package:omi/utils/analytics/analytics_manager.dart'; + +/// The arms of the Settings plan card A/B (`settings-plan-card`). +enum SettingsPlanCardArm { + /// The Plan & Usage row with the minutes meter and a full-width Upgrade under it. + control('control'), + + /// A plan card: the plan name large, the minutes meter, then a full-width Upgrade. + card('card'); + + const SettingsPlanCardArm(this.wire); + + final String wire; + + static SettingsPlanCardArm? fromWire(String? value) { + for (final arm in values) { + if (arm.wire == value) return arm; + } + return null; + } +} + +/// Which plan section Free and Plus readers see on the Settings sheet. +/// +/// Lifecycle: experiment; owner: yinka; review-by: 2026-11-15; default control. +/// Pre-registration: product/experiments/settings-plan-card.md. +/// +/// Fail-closed: no identity, a slow read or an unknown variant all mean control, and such a +/// fallback is never recorded as an exposure, so only readers PostHog actually assigned are counted. +abstract final class SettingsPlanCardExperiment { + static const enabledFlag = 'settings-plan-card'; + + /// Debug builds can force an arm: `--dart-define=OMI_SETTINGS_PLAN_CARD=card`. + static const _debugArm = String.fromEnvironment('OMI_SETTINGS_PLAN_CARD'); + + static SettingsPlanCardArm? _cached; + static bool _cachedFromFlag = false; + static String? _cachedIdentity; + + /// The arm cached for the current identity, if [resolve] already ran; lets the sheet draw the + /// right section on its first frame after the first open. + static SettingsPlanCardArm? get cached => _cachedIdentity == AnalyticsManager().settledDistinctId ? _cached : null; + + static Future resolve({Future Function(String key)? readVariant}) async { + if (kDebugMode && _debugArm.isNotEmpty) { + return SettingsPlanCardArm.fromWire(_debugArm) ?? SettingsPlanCardArm.control; + } + final identity = AnalyticsManager().settledDistinctId; + if (readVariant == null && _cached != null && _cachedIdentity == identity) return _cached!; + SettingsPlanCardArm? arm; + try { + final variant = await (readVariant ?? AnalyticsManager().getFeatureFlagVariant)(enabledFlag) + .timeout(const Duration(seconds: 2)); + arm = SettingsPlanCardArm.fromWire(variant); + } catch (_) { + arm = null; + } + _cachedFromFlag = arm != null; + _cached = arm ?? SettingsPlanCardArm.control; + _cachedIdentity = identity; + return _cached!; + } + + /// Records the exposure PostHog Experiments counts (`$feature_flag_called`, once per identity and + /// arm) at the moment the section is drawn for a Free or Plus reader. Skipped for a fallback arm. + static bool recordExposure(SettingsPlanCardArm arm, {bool Function(String key, String variant)? record}) { + if (!_cachedFromFlag && record == null) return false; + return (record ?? AnalyticsManager().recordFeatureFlagExposure)(enabledFlag, arm.wire); + } + + @visibleForTesting + static void resetForTesting() { + _cached = null; + _cachedFromFlag = false; + _cachedIdentity = null; + } +} diff --git a/app/lib/ui/components/omi_page_states.dart b/app/lib/ui/components/omi_page_states.dart index 296fe6c01c2..5a1b13e4af2 100644 --- a/app/lib/ui/components/omi_page_states.dart +++ b/app/lib/ui/components/omi_page_states.dart @@ -39,8 +39,9 @@ class OmiLoadingState extends StatelessWidget { /// when the action is how the page gets its first row ("New Memory"). /// /// The glyph is either a Material [icon] or any [glyph] widget — for example -/// `FaIcon(FontAwesomeIcons.key)` when the screen's other controls use FontAwesome. Exactly one of -/// the two is given; either way it is drawn 40pt in [OmiColors.textTertiary]. +/// `FaIcon(FontAwesomeIcons.key)` when the screen's other controls use FontAwesome. At most one of +/// the two is given; either way it is drawn 40pt in [OmiColors.textTertiary]. With neither, the +/// state is text only (Home and Tasks before anything is in them). class OmiEmptyState extends StatelessWidget { const OmiEmptyState({ super.key, @@ -51,7 +52,7 @@ class OmiEmptyState extends StatelessWidget { this.message, this.messageLayoutReference, this.action, - }) : assert((icon == null) != (glyph == null), 'Give OmiEmptyState exactly one of icon or glyph'); + }) : assert(icon == null || glyph == null, 'Give OmiEmptyState at most one of icon or glyph'); final IconData? icon; @@ -73,7 +74,7 @@ class OmiEmptyState extends StatelessWidget { @override Widget build(BuildContext context) { return _StateLayout( - glyph: glyph ?? Icon(icon), + glyph: glyph ?? (icon == null ? null : Icon(icon)), title: title, titleLayoutReference: titleLayoutReference, message: message, @@ -117,14 +118,9 @@ class OmiErrorState extends StatelessWidget { class _StateLayout extends StatelessWidget { const _StateLayout( - {required this.glyph, - this.title, - this.titleLayoutReference, - this.message, - this.messageLayoutReference, - this.action}); - - final Widget glyph; + {this.glyph, this.title, this.titleLayoutReference, this.message, this.messageLayoutReference, this.action}); + + final Widget? glyph; final String? title; final String? titleLayoutReference; final String? message; @@ -140,14 +136,15 @@ class _StateLayout extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - ExcludeSemantics( - child: IconTheme.merge( - data: IconThemeData(size: 40, color: OmiColors.textTertiary), - child: glyph, + if (glyph != null) + ExcludeSemantics( + child: IconTheme.merge( + data: IconThemeData(size: 40, color: OmiColors.textTertiary), + child: glyph!, + ), ), - ), if (title != null) ...[ - const SizedBox(height: OmiSpacing.md), + if (glyph != null) const SizedBox(height: OmiSpacing.md), Semantics( header: true, child: titleLayoutReference == null diff --git a/app/lib/ui/components/omi_refresh_control.dart b/app/lib/ui/components/omi_refresh_control.dart new file mode 100644 index 00000000000..95a064c4930 --- /dev/null +++ b/app/lib/ui/components/omi_refresh_control.dart @@ -0,0 +1,124 @@ +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; + +import 'package:omi/ui/components/omi_spinner.dart'; +import 'package:omi/ui/omi_tokens.dart'; + +/// Pull-to-refresh for a [CustomScrollView] on iOS: a thin ring draws itself as the list is pulled, +/// ticks once when letting go would refresh, then becomes the [OmiSpinner] while it loads. +/// +/// Put it first in the slivers and keep the stock `RefreshIndicator` for Android with +/// [OmiRefreshControl.materialPredicate], which turns the Material one off where this one shows: +/// +/// ```dart +/// RefreshIndicator( +/// notificationPredicate: OmiRefreshControl.materialPredicate, +/// onRefresh: refresh, +/// child: CustomScrollView(slivers: [if (OmiRefreshControl.supported) OmiRefreshControl(onRefresh: refresh), ...]), +/// ) +/// ``` +class OmiRefreshControl extends StatelessWidget { + const OmiRefreshControl({super.key, required this.onRefresh}); + + final Future Function() onRefresh; + + /// iOS only: the control needs the bouncing overscroll iOS lists have. + static bool get supported => !_forceMaterial && Platform.isIOS; + + /// Tests pin the Material path. + static bool _forceMaterial = false; + @visibleForTesting + static set debugForceMaterial(bool value) => _forceMaterial = value; + + /// For the Material `RefreshIndicator` that wraps the same list: off wherever this control shows. + static bool materialPredicate(ScrollNotification notification) => !supported && notification.depth == 0; + + static const double _trigger = 88; + static const double _extent = 56; + + @override + Widget build(BuildContext context) { + return CupertinoSliverRefreshControl( + refreshTriggerPullDistance: _trigger, + refreshIndicatorExtent: _extent, + onRefresh: onRefresh, + builder: (context, mode, pulled, trigger, extent) => + _PullIndicator(mode: mode, progress: (pulled / trigger).clamp(0.0, 1.0)), + ); + } +} + +class _PullIndicator extends StatefulWidget { + const _PullIndicator({required this.mode, required this.progress}); + + final RefreshIndicatorMode mode; + final double progress; + + @override + State<_PullIndicator> createState() => _PullIndicatorState(); +} + +class _PullIndicatorState extends State<_PullIndicator> { + @override + void didUpdateWidget(covariant _PullIndicator old) { + super.didUpdateWidget(old); + // One tick at the moment letting go would refresh. + if (widget.mode == RefreshIndicatorMode.armed && old.mode == RefreshIndicatorMode.drag) OmiHaptics.selection(); + } + + @override + Widget build(BuildContext context) { + final loading = widget.mode == RefreshIndicatorMode.armed || widget.mode == RefreshIndicatorMode.refresh; + final done = widget.mode == RefreshIndicatorMode.done || widget.mode == RefreshIndicatorMode.inactive; + final p = widget.progress; + return Center( + child: AnimatedOpacity( + opacity: done ? 0 : (p * 1.8).clamp(0.0, 1.0), + duration: const Duration(milliseconds: 160), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 180), + child: loading + ? const OmiSpinner(key: ValueKey('spin'), size: OmiSpinnerSize.small) + : SizedBox( + key: const ValueKey('ring'), + width: 22, + height: 22, + child: CustomPaint(painter: _PullRing(progress: p, color: OmiColors.textPrimary)), + ), + ), + ), + ); + } +} + +/// A ring that draws clockwise from the top as the list is pulled, and a dot that grows at its +/// centre: full ring + dot = let go to refresh. +class _PullRing extends CustomPainter { + _PullRing({required this.progress, required this.color}); + + final double progress; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final c = size.center(Offset.zero); + final r = size.shortestSide / 2 - 1; + final track = Paint() + ..color = color.withValues(alpha: 0.12) + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + canvas.drawCircle(c, r, track); + final arc = Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.round + ..strokeWidth = 2; + canvas.drawArc(Rect.fromCircle(center: c, radius: r), -math.pi / 2, 2 * math.pi * progress, false, arc); + canvas.drawCircle(c, 3.5 * Curves.easeOutBack.transform(progress), Paint()..color = color); + } + + @override + bool shouldRepaint(covariant _PullRing old) => old.progress != progress || old.color != color; +} diff --git a/app/lib/ui/omi_routes.dart b/app/lib/ui/omi_routes.dart index a5a33bbb788..afd6305f37b 100644 --- a/app/lib/ui/omi_routes.dart +++ b/app/lib/ui/omi_routes.dart @@ -40,3 +40,18 @@ Route omiPageRoute({ ); } } + +/// Replaces the whole navigator stack with [builder]'s page by fading it in: for hand-offs that +/// leave nothing to go back to (onboarding done → Home). A slide there shows the incoming page +/// moving over whatever the old root is redrawing underneath (often the same page), which reads +/// as a double. Use with `pushAndRemoveUntil(..., (_) => false)`; never for an ordinary push. +Route omiRootRoute({required WidgetBuilder builder, RouteSettings? settings}) { + return PageRouteBuilder( + settings: settings, + transitionDuration: const Duration(milliseconds: 260), + reverseTransitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, animation, secondaryAnimation) => builder(context), + transitionsBuilder: (context, animation, secondaryAnimation, child) => + FadeTransition(opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child), + ); +} diff --git a/app/lib/ui/ui.dart b/app/lib/ui/ui.dart index c4d10b330c8..cc738767ee2 100644 --- a/app/lib/ui/ui.dart +++ b/app/lib/ui/ui.dart @@ -40,6 +40,7 @@ export 'components/omi_nav_buttons.dart'; export 'components/omi_page_states.dart'; export 'components/omi_partial_notice.dart'; export 'components/omi_permission_row.dart'; +export 'components/omi_refresh_control.dart'; export 'components/omi_row_menu.dart'; export 'components/omi_search_field.dart'; export 'components/omi_settings.dart'; diff --git a/app/lib/utils/analytics/analytics_manager.dart b/app/lib/utils/analytics/analytics_manager.dart index b6124400f25..c1bc69b1e97 100644 --- a/app/lib/utils/analytics/analytics_manager.dart +++ b/app/lib/utils/analytics/analytics_manager.dart @@ -859,6 +859,13 @@ class AnalyticsManager { void onboardingUserAcquisitionSource(String source) => track('User Acquisition Source', properties: {'source': source}); + /// How the reader left the Connect Omi step: `connected`, `ordered` or `not_ordered`. Also kept on + /// the person, so buyers who say they ordered can be matched to Shopify orders later. + void onboardingDeviceChoice(String choice) { + track('Onboarding Device Choice', properties: {'choice': choice}); + setUserProperty('Onboarding Device Choice', choice); + } + // Interactive device onboarding void deviceOnboardingStarted({String source = 'auto'}) => track('Device Onboarding Started', properties: {'source': source}); diff --git a/app/lib/utils/analytics/registry/events.g.dart b/app/lib/utils/analytics/registry/events.g.dart index 6ed292df719..0e0caf48181 100644 --- a/app/lib/utils/analytics/registry/events.g.dart +++ b/app/lib/utils/analytics/registry/events.g.dart @@ -2389,7 +2389,8 @@ enum PaywallOpenedSource { screenContextGate("screen_context_gate"), mcpDailyCap("mcp_daily_cap"), budget80("budget_80"), - onboarding("onboarding"); + onboarding("onboarding"), + appOpenReask("app_open_reask"); const PaywallOpenedSource(this.wireName); final String wireName; } @@ -2824,7 +2825,8 @@ enum PaywallVariantShownSource { screenContextGate("screen_context_gate"), mcpDailyCap("mcp_daily_cap"), budget80("budget_80"), - onboarding("onboarding"); + onboarding("onboarding"), + appOpenReask("app_open_reask"); const PaywallVariantShownSource(this.wireName); final String wireName; } diff --git a/app/lib/widgets/freemium_switch_dialog.dart b/app/lib/widgets/freemium_switch_dialog.dart index 9da0808b932..82653dc98ed 100644 --- a/app/lib/widgets/freemium_switch_dialog.dart +++ b/app/lib/widgets/freemium_switch_dialog.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:omi/utils/platform/platform_manager.dart'; import 'package:provider/provider.dart'; -import 'package:omi/pages/paywall/paywall_router.dart'; -import 'package:omi/pages/settings/widgets/plans_sheet.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/services/capture/freemium_threshold_tracker.dart'; @@ -52,20 +50,10 @@ class FreemiumSwitchHandler { PromptPriority.low, // Still relevant when it is finally shown (the plan may have changed meanwhile). canShowNow: () => _eligible(context, captureProvider), - show: (promptContext) async { - if (await PaywallRouter.openVariant(promptContext, source: 'freemium_switch')) return; - if (!promptContext.mounted) return; - final plan = promptContext.read().subscription?.subscription.plan.wireName ?? 'unknown'; - PlatformManager.instance.analytics.paywallOpened('freemium_switch', currentPlan: plan); - final title = await plansSheetTitle(promptContext); - if (!promptContext.mounted) return; - return showOmiSheet( - context: promptContext, - title: title, - padding: EdgeInsets.zero, - builder: (_) => const _PlansSheetWrapper(), - ); - }, + show: (promptContext) => openUpgradePaywall( + promptContext, + source: 'freemium_switch', + ), ); } @@ -91,49 +79,3 @@ class FreemiumSwitchHandler { _freemiumService.resetDialogShownFlag(); } } - -/// Wrapper widget to create animation controllers for PlansSheet -class _PlansSheetWrapper extends StatefulWidget { - const _PlansSheetWrapper(); - - @override - State<_PlansSheetWrapper> createState() => _PlansSheetWrapperState(); -} - -class _PlansSheetWrapperState extends State<_PlansSheetWrapper> with TickerProviderStateMixin { - late AnimationController _waveController; - late AnimationController _arrowController; - late Animation _arrowAnimation; - - @override - void initState() { - super.initState(); - _waveController = AnimationController(duration: const Duration(milliseconds: 18000), vsync: this)..repeat(); - - _arrowController = AnimationController(duration: const Duration(milliseconds: 800), vsync: this) - ..repeat(reverse: true); - - _arrowAnimation = Tween( - begin: 0, - end: 3, - ).animate(CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut)); - } - - @override - void dispose() { - _waveController.dispose(); - _arrowController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PlansSheet( - waveController: _waveController, - notesController: _waveController, - arrowController: _arrowController, - arrowAnimation: _arrowAnimation, - analyticsSource: 'freemium_switch', - ); - } -} diff --git a/app/lib/widgets/home_bottom_bar.dart b/app/lib/widgets/home_bottom_bar.dart index 8fffe843e35..f439d043baf 100644 --- a/app/lib/widgets/home_bottom_bar.dart +++ b/app/lib/widgets/home_bottom_bar.dart @@ -18,8 +18,8 @@ const double kHomeBottomFadeHeight = 20; /// Gap between the chat bar and the system inset (or the screen edge when there is none). const double kHomeChatBarBottomGap = 8; -/// Height of Home's floating chat bar. -const double kHomeChatBarHeight = 62; +/// Height of Home's floating bottom controls (Ask Omi and the record pill). +const double kHomeChatBarHeight = 56; /// Space every Home-shell page leaves below its last row so it can scroll clear of the screen edge. const double kHomeScrollEndPadding = 24; diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 8a97cf1bbf7..13a7a6fe0d8 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -211,6 +211,7 @@ flutter: - assets/models/silero_vad.onnx - assets/images/stars.png - assets/images/herologo.png + - assets/videos/onboarding_launch_loop.mp4 - assets/fonts/ - assets/images/ - assets/test/tts_progressive_probe.mp3 diff --git a/app/test/unit/paywall_experiment_test.dart b/app/test/unit/paywall_experiment_test.dart index c5d9a67839a..281175dc7b5 100644 --- a/app/test/unit/paywall_experiment_test.dart +++ b/app/test/unit/paywall_experiment_test.dart @@ -15,6 +15,14 @@ void main() { }); group('parse', () { + test('control carries the onboarding placement from its payload', () { + final config = PaywallExperiment.parse(variant: 'control', payload: '{"onboarding_paywall": true}'); + expect(config.isControl, isTrue); + expect(config.onboardingPaywall, isTrue); + expect(PaywallExperiment.parse(variant: 'control', payload: {'url': 'https://x.example'}), + PaywallExperimentConfig.control); + }); + test('control, null and unknown variants are control', () { expect(PaywallExperiment.parse(variant: 'control'), PaywallExperimentConfig.control); expect(PaywallExperiment.parse(variant: null), PaywallExperimentConfig.control); @@ -69,18 +77,21 @@ void main() { expect(payloadReads, ['paywall-variant']); }); - test('control skips the payload read', () async { - var payloadReads = 0; - final config = await PaywallExperiment.resolve( + test('control reads its payload for the onboarding placement', () async { + final plain = await PaywallExperiment.resolve( identity: () => 'u', readVariant: (_) async => 'control', - readPayload: (_) async { - payloadReads++; - return null; - }, + readPayload: (_) async => null, ); - expect(config, PaywallExperimentConfig.control); - expect(payloadReads, 0); + expect(plain, PaywallExperimentConfig.control); + PaywallExperiment.resetForTesting(); + final placed = await PaywallExperiment.resolve( + identity: () => 'u', + readVariant: (_) async => 'control', + readPayload: (_) async => {'onboarding_paywall': true}, + ); + expect(placed.isControl, isTrue); + expect(placed.onboardingPaywall, isTrue); }); test('an unresolved read is control but not cached', () async { diff --git a/app/test/unit/paywall_reask_test.dart b/app/test/unit/paywall_reask_test.dart new file mode 100644 index 00000000000..c1b844ee9d6 --- /dev/null +++ b/app/test/unit/paywall_reask_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/services/experiments/paywall_reask.dart'; + +// The re-ask comes back 2, 3, 5 and then every 7 days after the last paywall, never on the +// first Home visit, and counts both arms at the same moments. + +void main() { + final day0 = DateTime(2026, 10, 14, 9); + DateTime day(int n, [int hour = 9]) => DateTime(day0.year, day0.month, day0.day + n, hour); + + late List exposures; + late String? variant; + + Future check(DateTime now) => PaywallReask.checkDue( + now: now, + readVariant: (_) async => variant, + recordExposure: (key, value) { + exposures.add('$key=$value'); + return true; + }, + ); + + setUp(() async { + SharedPreferences.setMockInitialValues({'uid': 'reader-1'}); + await SharedPreferencesUtil.init(); + exposures = []; + variant = 'test'; + }); + + test('the gaps widen and then hold at a week', () { + expect([for (var i = 0; i < 7; i++) PaywallReask.gapAfter(i)], [2, 3, 5, 7, 7, 7, 7]); + }); + + test('calendar days, not hours: a late open and an early open two days later count as 2', () { + expect(PaywallReask.daysBetween(DateTime(2026, 10, 14, 23), DateTime(2026, 10, 16, 1)), 2); + expect(PaywallReask.daysBetween(DateTime(2026, 10, 14, 1), DateTime(2026, 10, 14, 23)), 0); + }); + + test('never on the first Home visit; due two days later; then the schedule widens', () async { + expect(await check(day0), isFalse, reason: 'first visit starts the clock'); + expect(exposures, isEmpty); + expect(await check(day(1)), isFalse); + expect(await check(day(2)), isTrue, reason: 'gap 2 after the first visit'); + expect(await check(day(2, 18)), isFalse, reason: 'at most once a day'); + expect(await check(day(4)), isFalse); + expect(await check(day(5)), isTrue, reason: 'second gap is 3'); + expect(await check(day(9)), isFalse); + expect(await check(day(10)), isTrue, reason: 'third gap is 5'); + expect(await check(day(16)), isFalse); + expect(await check(day(17)), isTrue, reason: 'then weekly'); + expect(await check(day(24)), isTrue, reason: 'still weekly'); + expect(exposures, everyElement('paywall-reask=test')); + expect(exposures, hasLength(5)); + }); + + test('any other paywall restarts the clock', () async { + await check(day0); + PaywallReask.noteShown(now: day(1)); // e.g. a locked conversation + expect(await check(day(2)), isFalse); + expect(await check(day(3)), isTrue); + }); + + test('control is exposed on the same schedule but shows nothing', () async { + variant = 'control'; + await check(day0); + expect(await check(day(2)), isFalse); + expect(exposures, ['paywall-reask=control']); + expect(await check(day(4)), isFalse); + expect(await check(day(5)), isFalse); + expect(exposures, hasLength(2), reason: 'control advances the same schedule'); + }); + + test('an unread flag shows nothing, exposes no one and keeps the reader due', () async { + variant = null; + await check(day0); + expect(await check(day(2)), isFalse); + expect(exposures, isEmpty); + variant = 'test'; + expect(await check(day(3)), isTrue, reason: 'still due once the flag is read'); + }); + + test('no signed-in reader: nothing happens', () async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + expect(await check(day(30)), isFalse); + expect(exposures, isEmpty); + }); +} diff --git a/app/test/widgets/action_items_row_gestures_test.dart b/app/test/widgets/action_items_row_gestures_test.dart index 08b164f5a22..d5384895a84 100644 --- a/app/test/widgets/action_items_row_gestures_test.dart +++ b/app/test/widgets/action_items_row_gestures_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:omi/backend/schema/schema.dart'; +import 'package:omi/pages/action_items/widgets/task_row_parts.dart'; import 'package:omi/env/env.dart'; import 'package:omi/providers/action_items_provider.dart'; import 'package:omi/utils/platform/platform_manager.dart'; @@ -68,11 +69,28 @@ void main() { await tester.drag(find.text('Book the venue'), const Offset(400, 0)); await tester.pumpAndSettle(); + // The row shows done, folds away, then the task is completed. + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); expect(completions, [true]); expect(provider.actionItems.firstWhere((i) => i.id == 'child').indentLevel, 1); }); + testWidgets('ticking a task shows it done at once, folds it away, then completes it', (tester) async { + final (_, _, completions) = await pumpPage(tester); + + await tester.tap(find.byType(TaskCompletionMark).last); + await tester.pump(const Duration(milliseconds: 100)); + final title = tester.widget(find.text('Book the venue')); + expect(title.style?.decoration, TextDecoration.lineThrough, reason: 'looks done right away'); + expect(completions, isEmpty, reason: 'the save waits for the fold'); + + await tester.pump(const Duration(seconds: 1)); + await tester.pumpAndSettle(); + expect(completions, [true]); + }); + testWidgets('swiping left on an indented task deletes it with Undo, no dialog', (tester) async { final (provider, deletes, _) = await pumpPage(tester); diff --git a/app/test/widgets/capture_home_ui_test.dart b/app/test/widgets/capture_home_ui_test.dart index c43e81f9c59..c870f733662 100644 --- a/app/test/widgets/capture_home_ui_test.dart +++ b/app/test/widgets/capture_home_ui_test.dart @@ -137,6 +137,9 @@ class _Capture extends ChangeNotifier implements CaptureProvider { Future pauseCapture() async => pauses++; @override Future resumeCapture() async => resumes++; + int finishes = 0; + @override + Future finishCapture() async => finishes++; @override Future streamRecording({bool resumeCapture = true}) async { phoneStarts++; @@ -548,10 +551,125 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); }); + group('live pill replaces the Home card while recording is healthy', () { + testWidgets('the phone pill shows the newest words; Home shows no card', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.phone)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_pill_last_words')), findsOneWidget); + expect(find.text('Keep the pendant flow as it is.'), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + + await pump(tester, const ConversationCaptureWidget(showsCall: true, deviceStatusInBottomBar: true), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.phone)); + expect(find.byType(LiveCaptureCard), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Home never shows the card: paused, failing, reconnecting or mic taken all live in the pill', + (tester) async { + for (final capture in [ + _Capture(_Live.phone, readerPaused: true), + _Capture(_Live.phone, failure: true), + _Capture(_Live.phone, interrupted: true), + _Capture(_Live.phone, interrupted: true, callActive: true), + ]) { + await pump(tester, const ConversationCaptureWidget(showsCall: true, deviceStatusInBottomBar: true), + device: _Device(connected: false, paired: false), capture: capture); + expect(find.byType(LiveCaptureCard), findsNothing); + await tester.pumpWidget(const SizedBox()); + } + }); + + testWidgets('a paused phone recording: the pill says Paused and its one control is Resume', (tester) async { + final capture = _Capture(_Live.phone, readerPaused: true); + await pump(tester, const HomeRecordButton(), device: _Device(connected: false, paired: false), capture: capture); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.textContaining(en.paused), findsOneWidget); + expect(find.byKey(const ValueKey('home_record_pill_resume_button')), findsOneWidget); + expect(find.byKey(const ValueKey('home_record_pill_stop_button')), findsNothing); + await tester.tap(find.byKey(const ValueKey('home_record_pill_resume_button'))); + await tester.pump(); + expect(capture.resumes, 1); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a phone recording reconnecting on its own says so in the pill, keeping Stop', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.phone, interrupted: true)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_record_pill_stop')), findsOneWidget); + expect(find.textContaining(en.reconnecting), findsOneWidget); + expect(find.textContaining(en.listening), findsNothing); + expect(find.byKey(const ValueKey('home_record_pill_stop_button')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a failing recording: tapping the pill opens why, the controls and the live transcript', + (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.phone, failure: true)); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('home_record_pill_stop'))); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('home_capture_sheet_explanation')), findsOneWidget); + expect(find.byType(LiveCaptureCard), findsOneWidget); + expect(find.byKey(const ValueKey('home_capture_sheet_transcript')), findsOneWidget); + expect(find.byKey(const ValueKey('home_capture_sheet_stop')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a paused pendant reads Paused in its pill and opens the controls on tap', (tester) async { + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.pendantPaused), device: _Device()); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_record_pill_device')), findsOneWidget); + expect(find.textContaining(en.paused), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('home_record_pill_device'))); + await tester.pumpAndSettle(); + expect(find.byType(LiveCaptureCard), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a pendant that drops mid-capture: the pill says Disconnected, and why on tap', (tester) async { + final device = _Device(); + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.pendant), device: device); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_record_pill_device')), findsOneWidget); + device.drop(); + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.idleDeviceConnected), device: device); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_record_pill_dropped')), findsOneWidget); + expect(find.textContaining(en.disconnected), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('home_record_pill_dropped'))); + await tester.pumpAndSettle(); + expect(find.text(en.capturePendantDisconnectedDetail), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a Transcribe Later pendant shows in the pill, not as New', (tester) async { + SharedPreferencesUtil().batchModeEnabled = true; + addTearDown(() => SharedPreferencesUtil().batchModeEnabled = false); + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.pendantBatch), device: _Device()); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_record_pill_new')), findsNothing); + expect(find.byKey(const ValueKey('home_record_pill_device')), findsOneWidget); + expect(find.textContaining(en.recording), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('the pendant pill shows the newest words too', (tester) async { + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.pendant), device: _Device(paired: true)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.byKey(const ValueKey('home_pill_last_words')), findsOneWidget); + await tester.pumpWidget(const SizedBox()); + }); + }); + group('record-with-this-phone button', () { - testWidgets('idle with calls hidden: a white dot and no ways-to-record badge', (tester) async { - await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.idle)); - expect(find.bySemanticsLabel(en.startRecording), findsOneWidget); + testWidgets('idle with calls hidden: + New, and no ways-to-record option', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.idle)); + expect(find.bySemanticsLabel(en.newRecording), findsOneWidget); expect(find.bySemanticsLabel(en.moreWaysToRecord), findsNothing); expect(find.byIcon(Icons.keyboard_arrow_down_rounded), findsNothing); await tester.longPress(find.byType(HomeRecordButton)); @@ -559,9 +677,10 @@ void main() { expect(find.text(en.recordWith), findsNothing, reason: 'the only extra way to record was a phone call'); }, skip: PhoneCallsFeature.visible); - testWidgets('idle: a white dot and a badge that opens the ways to record', (tester) async { - await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.idle)); - expect(find.bySemanticsLabel(en.startRecording), findsOneWidget); + testWidgets('idle: + New, whose long-press opens the ways to record', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.idle)); + expect(find.bySemanticsLabel(en.newRecording), findsOneWidget); await tester.tap(find.bySemanticsLabel(en.moreWaysToRecord)); await tester.pumpAndSettle(); expect(find.text(en.recordWith), findsOneWidget); @@ -569,24 +688,26 @@ void main() { expect(find.text(en.phoneCall), findsOneWidget); }, skip: !PhoneCallsFeature.visible); - testWidgets('while the pendant records, a tap explains instead of taking over', (tester) async { + testWidgets('while the pendant records, holding its pill offers the phone instead of taking over', (tester) async { final capture = _Capture(_Live.pendant); - await pump(tester, const HomeRecordButton(), capture: capture); - await tester.tap(find.bySemanticsLabel(en.startRecording)); - await tester.pumpAndSettle(); + await pump(tester, const HomeRecordButton(), capture: capture, device: _Device(paired: true)); + await tester.longPress(find.byKey(const ValueKey('home_record_pill_device'))); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // the live dot breathes, so never settles expect(find.text(en.pendantIsListeningTitle), findsOneWidget); expect(capture.phoneStarts, 0, reason: 'never a silent takeover'); await tester.tap(find.text(en.keepUsingPendant)); - await tester.pumpAndSettle(); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); // the live dot breathes, so never settles expect(find.text(en.pendantIsListeningTitle), findsNothing); expect(capture.phoneStarts, 0); }); testWidgets('a Transcribe Later pendant rejects phone takeover with visible feedback', (tester) async { final capture = _Capture(_Live.pendantBatch); - await pump(tester, const HomeRecordButton(), capture: capture); - await tester.tap(find.bySemanticsLabel(en.startRecording)); + await pump(tester, const HomeRecordButton(), device: _Device(connected: false, paired: false), capture: capture); + await tester.tap(find.bySemanticsLabel(en.newRecording)); await tester.pump(); expect(find.text(en.phoneRecordingBlockedByPendantBatch), findsOneWidget); expect(capture.phoneStarts, 0); @@ -595,8 +716,8 @@ void main() { testWidgets('a start that fails says so and never opens the capturing page', (tester) async { final capture = _Capture(_Live.idle)..phoneStartFailure = StateError('refused'); - await pump(tester, const HomeRecordButton(), capture: capture); - await tester.tap(find.bySemanticsLabel(en.startRecording)); + await pump(tester, const HomeRecordButton(), device: _Device(connected: false, paired: false), capture: capture); + await tester.tap(find.bySemanticsLabel(en.newRecording)); await tester.pump(); expect(capture.phoneStarts, 1); expect(find.text(en.somethingWentWrong), findsOneWidget); @@ -605,8 +726,8 @@ void main() { testWidgets('a start that resolves without phone ownership navigates nowhere', (tester) async { final capture = _Capture(_Live.idle); - await pump(tester, const HomeRecordButton(), capture: capture); - await tester.tap(find.bySemanticsLabel(en.startRecording)); + await pump(tester, const HomeRecordButton(), device: _Device(connected: false, paired: false), capture: capture); + await tester.tap(find.bySemanticsLabel(en.newRecording)); await tester.pump(); expect(capture.phoneStarts, 1); expect(find.byType(ConversationCapturingPage), findsNothing); @@ -615,28 +736,54 @@ void main() { testWidgets('during an Omi call the button never starts a recording', (tester) async { final capture = _Capture(_Live.idle); - await pump(tester, const HomeRecordButton(), capture: capture, call: _Call(PhoneCallState.active)); - await tester.tap(find.bySemanticsLabel(en.startRecording)); + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: capture, call: _Call(PhoneCallState.active)); + await tester.tap(find.bySemanticsLabel(en.newRecording)); expect(capture.phoneStarts, 0, reason: 'the tap opens the call page instead'); await tester.pumpWidget(const SizedBox()); // the call page itself is not under test here }); - testWidgets('the badge is fully tappable and does not cover the circle\'s centre', (tester) async { - await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.idle)); - final badge = tester.getRect(find - .ancestor(of: find.byIcon(Icons.keyboard_arrow_down_rounded), matching: find.byType(GestureDetector)) - .first); - final button = tester.getRect(find.byType(HomeRecordButton)); - expect(badge.width, greaterThanOrEqualTo(30)); - expect(button.inflate(0.1).contains(badge.topLeft) && button.inflate(0.1).contains(badge.bottomRight), isTrue, - reason: 'a Stack only hit-tests inside its own box'); - expect(badge.contains(button.center), isFalse); - }, skip: !PhoneCallsFeature.visible); + testWidgets('idle, the pill reads + New Recording', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.idle)); + expect(find.text(en.homeNewRecordingButton), findsOneWidget); + expect(find.byIcon(Icons.add_rounded), findsOneWidget); + }); - testWidgets('while the phone records, the button is its stop', (tester) async { - await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.phone)); - expect(find.bySemanticsLabel(en.stopRecording), findsOneWidget); - expect(find.bySemanticsLabel(en.moreWaysToRecord), findsNothing); + testWidgets('while the phone records, the pill shows it listening and holds its Stop', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(connected: false, paired: false), capture: _Capture(_Live.phone)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(en.listening), findsOneWidget); + expect(find.byKey(const ValueKey('home_record_pill_stop_button')), findsOneWidget); + final pill = tester.getSemantics(find.byKey(const ValueKey('home_record_pill_stop'))); + expect(pill.getSemanticsData().customSemanticsActionIds, isNotEmpty, reason: 'Stop reachable by VoiceOver'); + expect(find.text(en.homeNewRecordingButton), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('Stop on a phone recording that is reconnecting finishes it, never restarts it', (tester) async { + final capture = _Capture(_Live.phone, interrupted: true); + await pump(tester, const HomeRecordButton(), device: _Device(connected: false, paired: false), capture: capture); + await tester.tap(find.byKey(const ValueKey('home_record_pill_stop_button'))); + await tester.pump(); + expect(capture.finishes, 1); + expect(capture.phoneStarts, 0); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a connected pendant that is not capturing leaves + New in place', (tester) async { + await pump(tester, const HomeRecordButton(), + device: _Device(paired: true), capture: _Capture(_Live.idleDeviceConnected)); + expect(find.text(en.homeNewRecordingButton), findsOneWidget); + }); + + testWidgets('with a pendant connected and capturing, the pill shows it listening', (tester) async { + await pump(tester, const HomeRecordButton(), capture: _Capture(_Live.pendant), device: _Device(paired: true)); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text(en.homeNewRecordingButton), findsNothing); + expect(find.textContaining(en.listening), findsOneWidget); + await tester.pumpWidget(const SizedBox()); }); }); diff --git a/app/test/widgets/chat_welcome_test.dart b/app/test/widgets/chat_welcome_test.dart new file mode 100644 index 00000000000..8aef30cdbb4 --- /dev/null +++ b/app/test/widgets/chat_welcome_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/chat/widgets/chat_starters.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + Widget host({required bool animate}) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: const [Locale('en')], + home: Scaffold( + body: ChatGreeting( + isConnected: true, name: 'Yinka', hour: 19, todayCount: 0, welcome: true, animateWelcome: animate), + ), + ); + + testWidgets('the first time, Omi types its welcome in: dots, then the message', (tester) async { + expect(ChatWelcome.pending, isTrue); + expect(ChatWelcome.shown, isFalse); + await tester.pumpWidget(host(animate: true)); + expect(find.byKey(const Key('chat_welcome_typing')), findsOneWidget); + expect(find.text('What do you want to know?'), findsNothing); + + await tester.pump(const Duration(milliseconds: 1400)); + expect(find.byKey(const Key('chat_welcome_typing')), findsNothing); + expect(find.byKey(const Key('chat_welcome_message')), findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(ChatWelcome.shown, isTrue, reason: 'typed in once'); + expect(ChatWelcome.pending, isTrue, reason: 'still shown until the first message is sent'); + }); + + testWidgets('later visits show it settled; sending a message retires it', (tester) async { + await tester.pumpWidget(host(animate: false)); + expect(find.byKey(const Key('chat_welcome_typing')), findsNothing); + expect(find.textContaining("Hi, I'm Omi."), findsOneWidget); + + ChatWelcome.markDone(); + expect(ChatWelcome.pending, isFalse); + }); +} diff --git a/app/test/widgets/conversation_locked_preview_test.dart b/app/test/widgets/conversation_locked_preview_test.dart index bcbf2cc34da..1702865c924 100644 --- a/app/test/widgets/conversation_locked_preview_test.dart +++ b/app/test/widgets/conversation_locked_preview_test.dart @@ -8,14 +8,13 @@ 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/settings/usage_page.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; const _title = 'Planning the next team meeting'; // The locked preview routes to the usage page, so its action says so. -const _upgrade = 'View Usage'; +const _upgrade = 'Upgrade'; class _Routes extends NavigatorObserver { final pushed = >[]; @@ -150,16 +149,14 @@ void main() { expect(find.text(_upgrade), findsNothing); }); - testWidgets('the waiting preview opens usage without forcing a paywall', (tester) async { + testWidgets('the upgrade action opens the plans sheet over the list, not the usage page', (tester) async { final routes = _Routes(); await pumpRow(tester, observer: routes); await tester.tap(find.byKey(const Key('locked_preview_action'))); expect(routes.pushed, hasLength(2)); - final route = routes.pushed.last as MaterialPageRoute; - final destination = route.builder(tester.element(find.byType(ConversationListItem))); - expect(destination, isA()); - expect((destination as UsagePage).showUpgradeDialog, isFalse); - // Check navigation without building the network-backed usage screen. + // The locked_content paywall (or its plans-sheet fallback) opens in place; no page push. + expect(routes.pushed.last, isA()); + // Check navigation without building the network-backed plans sheet. await tester.pumpWidget(const SizedBox.shrink()); }); @@ -202,10 +199,7 @@ void main() { } await tester.tap(find.byKey(const Key('locked_preview_action'))); expect(routes.pushed, hasLength(2)); - expect( - (routes.pushed.last as MaterialPageRoute).builder(tester.element(find.byType(LockedConversationRun))), - isA(), - ); + expect(routes.pushed.last, isA()); await tester.pumpWidget(const SizedBox.shrink()); }); @@ -264,12 +258,11 @@ void main() { await tester.tapAt(Offset(rect.left + 16, rect.center.dy)); expect(routes.pushed, hasLength(visible ? 2 : 1)); if (visible) { - expect((routes.pushed.last as MaterialPageRoute).builder(tester.element(find.byType(OmiLockedPreview))), - isA()); + // The paywall (or its plans-sheet fallback) opens over the list. + expect(routes.pushed.last, isA()); await tester.pumpWidget(const SizedBox.shrink()); } else { await tester.pumpAndSettle(); - expect(find.byType(UsagePage), findsNothing); expect(find.bySemanticsLabel(RegExp(_title)), findsNothing); expect(tester.takeException(), isNull); } @@ -289,8 +282,8 @@ void main() { await tester.tapAt(Offset(first.left + 16, (first.bottom + second.top) / 2)); expect(routes.pushed, hasLength(visible ? 2 : 1)); if (visible) { - expect((routes.pushed.last as MaterialPageRoute).builder(tester.element(find.byType(LockedConversationRun))), - isA()); + // The paywall (or its plans-sheet fallback) opens over the list. + expect(routes.pushed.last, isA()); await tester.pumpWidget(const SizedBox.shrink()); } else { await tester.pumpAndSettle(); diff --git a/app/test/widgets/empty_recording_message_render_test.dart b/app/test/widgets/empty_recording_message_render_test.dart index d3d52c9da77..5deb3eb53a1 100644 --- a/app/test/widgets/empty_recording_message_render_test.dart +++ b/app/test/widgets/empty_recording_message_render_test.dart @@ -1,5 +1,6 @@ import 'dart:convert'; import 'dart:io'; +import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; diff --git a/app/test/widgets/grow_route_test.dart b/app/test/widgets/grow_route_test.dart new file mode 100644 index 00000000000..4d7bfde2b72 --- /dev/null +++ b/app/test/widgets/grow_route_test.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/pages/home/widgets/grow_route.dart'; + +// The live transcript grows out of Home's record pill: it starts as the pill's shape, widens to +// the screen, runs back into the pill on close, and still leaves with the iOS edge swipe. +void main() { + final pillKey = GlobalKey(); + const pillRect = Rect.fromLTWH(60, 700, 300, 56); + + Future pump(WidgetTester tester) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + await tester.pumpWidget(MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: Stack(children: [ + Positioned.fromRect( + rect: pillRect, + child: GestureDetector( + key: pillKey, + onTap: () => pushGrowingFrom( + context, + from: pillKey, + page: const Scaffold(body: Center(child: Text('transcript'))), + ), + child: const ColoredBox(color: Colors.white), + ), + ), + ]), + ), + ), + )); + } + + Rect clip(WidgetTester tester) { + final render = tester.renderObject( + find + .ancestor( + of: find.text('transcript', skipOffstage: false), matching: find.byType(ClipRRect, skipOffstage: false)) + .first, + ); + return render.clipper!.getClip(render.size).outerRect; + } + + testWidgets('opens from the pill, widens to the screen, and closes back into it', (tester) async { + await pump(tester); + await tester.tap(find.byKey(pillKey)); + await tester.pump(); + // The first frame is the offstage measuring pass; the transition starts on the next one. + await tester.pump(); + expect(clip(tester), pillRect, reason: 'the first frame is the pill itself'); + + await tester.pump(const Duration(milliseconds: 230)); + final mid = clip(tester); + expect(mid.width, greaterThan(pillRect.width)); + expect(mid.height, greaterThan(pillRect.height)); + expect(mid.height, lessThan(800)); + + await tester.pumpAndSettle(); + expect(find.text('transcript'), findsOneWidget); + + Navigator.of(tester.element(find.text('transcript'))).pop(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + expect(clip(tester).height, lessThan(800), reason: 'it shrinks back toward the pill'); + await tester.pumpAndSettle(); + expect(find.text('transcript'), findsNothing); + }); + + testWidgets('the iOS edge swipe still closes it', (tester) async { + await pump(tester); + await tester.tap(find.byKey(pillKey)); + await tester.pumpAndSettle(); + + final gesture = await tester.startGesture(const Offset(5, 400)); + await gesture.moveBy(const Offset(300, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.text('transcript'), findsNothing); + }, variant: TargetPlatformVariant.only(TargetPlatform.iOS)); +} diff --git a/app/test/widgets/home_empty_conversations_test.dart b/app/test/widgets/home_empty_conversations_test.dart index d77f83a4975..1b70d0d6e49 100644 --- a/app/test/widgets/home_empty_conversations_test.dart +++ b/app/test/widgets/home_empty_conversations_test.dart @@ -11,7 +11,7 @@ import '../support/typed_conversation_screen.dart'; void main() { for (final brightness in Brightness.values) { - testWidgets('Home keeps its empty icon, title and layout after loading in $brightness', (tester) async { + testWidgets('Home keeps its text-only empty title and layout after loading in $brightness', (tester) async { final semantics = tester.ensureSemantics(); final previousPalette = OmiColors.active; OmiColors.active = OmiColors.forBrightness(brightness); @@ -44,7 +44,7 @@ void main() { final icon = find.byIcon(Icons.forum_rounded); expect(title, findsOneWidget); expect(hint, findsNothing); - expect(icon, findsOneWidget); + expect(icon, findsNothing); expect(find.text(l10n.tasksEmptyStateMessage), findsNothing); expect(find.bySemanticsLabel(l10n.tasksEmptyStateMessage), findsNothing); expect(find.bySemanticsLabel(l10n.noConversationsHeroMessage), findsNothing); @@ -57,10 +57,9 @@ void main() { expect(find.byKey(const ValueKey('omi.conversations.empty')), findsOneWidget); expect(title, findsOneWidget); expect(hint, findsNothing); - expect(icon, findsOneWidget); + expect(icon, findsNothing); expect(tester.getRect(title), initialTitleRect); expect(tester.widget(title).style!.color, OmiColors.textPrimary); - expect(IconTheme.of(tester.element(icon)).color, OmiColors.textTertiary); // A later outage must still show an error, and retry must restore the full hero. status = 503; @@ -73,7 +72,7 @@ void main() { await tester.tap(find.text(l10n.retry)); await tester.pump(); expect(provider.apiViewState.phase, ApiViewPhase.empty); - expect(icon, findsOneWidget); + expect(icon, findsNothing); expect(hint, findsNothing); expect(tester.getRect(title), initialTitleRect); diff --git a/app/test/widgets/memory_graph_fit_to_bounds_test.dart b/app/test/widgets/memory_graph_fit_to_bounds_test.dart index ae0f85ff2ee..85d4d8691a9 100644 --- a/app/test/widgets/memory_graph_fit_to_bounds_test.dart +++ b/app/test/widgets/memory_graph_fit_to_bounds_test.dart @@ -11,20 +11,20 @@ import 'package:omi/pages/onboarding/knowledge_graph_step.dart'; import 'package:omi/utils/platform/platform_manager.dart'; Future> _sampleFiveNodeGraph() async => { - 'nodes': [ - {'id': '1', 'label': 'Sam', 'node_type': 'user'}, - {'id': '2', 'label': 'Coffee', 'node_type': 'thing'}, - {'id': '3', 'label': 'Work', 'node_type': 'thing'}, - {'id': '4', 'label': 'Alice', 'node_type': 'person'}, - {'id': '5', 'label': 'Gym', 'node_type': 'place'}, - ], - 'edges': [ - {'source_id': '1', 'target_id': '2', 'label': 'drinks'}, - {'source_id': '1', 'target_id': '3', 'label': 'works_at'}, - {'source_id': '1', 'target_id': '4', 'label': 'knows'}, - {'source_id': '1', 'target_id': '5', 'label': 'goes_to'}, - ] - }; + 'nodes': [ + {'id': '1', 'label': 'Sam', 'node_type': 'user'}, + {'id': '2', 'label': 'Coffee', 'node_type': 'thing'}, + {'id': '3', 'label': 'Work', 'node_type': 'thing'}, + {'id': '4', 'label': 'Alice', 'node_type': 'person'}, + {'id': '5', 'label': 'Gym', 'node_type': 'place'}, + ], + 'edges': [ + {'source_id': '1', 'target_id': '2', 'label': 'drinks'}, + {'source_id': '1', 'target_id': '3', 'label': 'works_at'}, + {'source_id': '1', 'target_id': '4', 'label': 'knows'}, + {'source_id': '1', 'target_id': '5', 'label': 'goes_to'}, + ] +}; Widget _app(Widget home) { return MaterialApp( diff --git a/app/test/widgets/offline_sync_storage_contrast_test.dart b/app/test/widgets/offline_sync_storage_contrast_test.dart index b8ace53e2d5..5a4779f3353 100644 --- a/app/test/widgets/offline_sync_storage_contrast_test.dart +++ b/app/test/widgets/offline_sync_storage_contrast_test.dart @@ -125,7 +125,7 @@ void main() { final title = find.text('No conversations yet'); expect(title.hitTestable(), findsOneWidget); expect(contrast(tester.widget(title).style!.color!, OmiColors.surface0), greaterThanOrEqualTo(4.5)); - expect(find.byIcon(Icons.forum_rounded), findsOneWidget); + expect(find.byIcon(Icons.forum_rounded), findsNothing); }); } testWidgets('nonzero counts retain category and clear-all callbacks', (tester) async { diff --git a/app/test/widgets/paywall_router_test.dart b/app/test/widgets/paywall_router_test.dart index e6f46c189a9..e57fc4bb025 100644 --- a/app/test/widgets/paywall_router_test.dart +++ b/app/test/widgets/paywall_router_test.dart @@ -92,4 +92,51 @@ void main() { expect(shown, isFalse); expect(exposures, isEmpty); }); + + group('exposeControl (end-of-onboarding placement)', () { + const placed = PaywallExperimentConfig(variant: PaywallVariant.control, onboardingPaywall: true); + + testWidgets('a free control reader is exposed as control', (tester) async { + final context = await _context(tester, UsageProvider()); + final exposures = []; + final exposed = PaywallRouter.exposeControl(context, config: placed, record: (key, variant) { + exposures.add('$key=$variant'); + return true; + }); + expect(exposed, isTrue); + expect(exposures, ['paywall-variant=control']); + }); + + testWidgets('a variant reader is not exposed here (openVariant does that)', (tester) async { + final context = await _context(tester, UsageProvider()); + final exposures = []; + final exposed = PaywallRouter.exposeControl( + context, + config: const PaywallExperimentConfig(variant: PaywallVariant.outline, onboardingPaywall: true), + record: (key, variant) { + exposures.add('$key=$variant'); + return true; + }, + ); + expect(exposed, isFalse); + expect(exposures, isEmpty); + }); + + testWidgets('paid readers and hidden subscription UI are never exposed', (tester) async { + for (final response in [ + _response(plan: PlanType.unlimitedV2), + _response(plan: PlanType.basic, showSubscriptionUi: false), + ]) { + final usage = UsageProvider()..debugSetSubscription(response); + final context = await _context(tester, usage); + final exposures = []; + final exposed = PaywallRouter.exposeControl(context, config: placed, record: (key, variant) { + exposures.add('$key=$variant'); + return true; + }); + expect(exposed, isFalse); + expect(exposures, isEmpty); + } + }); + }); } diff --git a/app/test/widgets/settings/settings_plan_section_test.dart b/app/test/widgets/settings/settings_plan_section_test.dart new file mode 100644 index 00000000000..088a5ad54f6 --- /dev/null +++ b/app/test/widgets/settings/settings_plan_section_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/settings_plan_section.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/settings_plan_card_experiment.dart'; + +/// The Settings plan section is a plan card (name, Upgrade, this month's minutes) for Free and Plus, +/// and leaves the plain Plan & Usage row for every other plan. +class _Usage extends ChangeNotifier implements UsageProvider { + _Usage(this.subscription, {this.showSubscriptionUI = true}); + + @override + final UserSubscriptionResponse? subscription; + + @override + final bool showSubscriptionUI; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +UserSubscriptionResponse _response(PlanType plan, {int usedSeconds = 0, int limitSeconds = 0}) => + UserSubscriptionResponse( + subscription: Subscription(plan: plan, status: SubscriptionStatus.active), + transcriptionSecondsUsed: usedSeconds, + transcriptionSecondsLimit: limitSeconds, + wordsTranscribedUsed: 0, + wordsTranscribedLimit: 0, + insightsGainedUsed: 0, + insightsGainedLimit: 0, + ); + +void _noop() {} + +void main() { + final en = lookupAppLocalizations(const Locale('en')); + const row = Text('Plan row', key: ValueKey('plan_row')); + + Future pump(WidgetTester tester, UsageProvider usage, + {SettingsPlanCardArm arm = SettingsPlanCardArm.card}) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: usage, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: SettingsPlanSection(row: row, planName: 'Free', onOpen: _noop, arm: arm)), + ), + ), + ); + } + + testWidgets('Free is a plan card: name, minutes used and Upgrade, in place of the row', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic, usedSeconds: 240 * 60, limitSeconds: 300 * 60))); + + expect(find.byKey(const ValueKey('plan_row')), findsNothing); + expect(find.byKey(const ValueKey('settings_row_planAndUsage')), findsOneWidget); + expect(find.text('Free'), findsOneWidget); + expect(find.text(en.minsUsedThisMonth('240', 300)), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.text(en.upgrade), findsOneWidget); + }); + + testWidgets('tapping the card outside Upgrade opens Plan & Usage', (tester) async { + var opened = 0; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: _Usage(_response(PlanType.basic, usedSeconds: 60, limitSeconds: 300 * 60)), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: SettingsPlanSection( + row: row, + planName: 'Free', + onOpen: () => opened++, + arm: SettingsPlanCardArm.card, + ), + ), + ), + ), + ); + await tester.tap(find.text('Free')); + expect(opened, 1); + }); + + testWidgets('control keeps the row with minutes and a full-width Upgrade under it', (tester) async { + await pump( + tester, + _Usage(_response(PlanType.basic, usedSeconds: 240 * 60, limitSeconds: 300 * 60)), + arm: SettingsPlanCardArm.control, + ); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.text('Free'), findsNothing); + expect(find.text(en.minsUsedThisMonth('240', 300)), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsOneWidget); + }); + + group('experiment', () { + tearDown(SettingsPlanCardExperiment.resetForTesting); + + test('an unknown or missing variant falls back to control and is not counted', () async { + SettingsPlanCardExperiment.resetForTesting(); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => null), SettingsPlanCardArm.control); + expect(SettingsPlanCardExperiment.recordExposure(SettingsPlanCardArm.control), isFalse); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => 'other'), SettingsPlanCardArm.control); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) => Future.error('offline')), + SettingsPlanCardArm.control); + }); + + test('an assigned arm is counted under the settings-plan-card flag', () async { + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => 'card'), SettingsPlanCardArm.card); + final recorded = []; + SettingsPlanCardExperiment.recordExposure( + SettingsPlanCardArm.card, + record: (key, variant) { + recorded.add('$key=$variant'); + return true; + }, + ); + expect(recorded, ['settings-plan-card=card']); + }); + }); + + testWidgets('Plus offers Upgrade to Unlimited', (tester) async { + await pump(tester, _Usage(_response(PlanType.plus, usedSeconds: 60, limitSeconds: 1500 * 60))); + + expect(find.text(en.upgradeToUnlimited), findsOneWidget); + expect(find.text(en.minsUsedThisMonth('1', 1500)), findsOneWidget); + }); + + testWidgets('Free with no transcription limit shows Upgrade without a meter', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic))); + + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsOneWidget); + }); + + for (final plan in [PlanType.unlimitedV2, PlanType.unlimited, PlanType.operator, PlanType.architect]) { + testWidgets('${plan.name} keeps the plain row', (tester) async { + await pump(tester, _Usage(_response(plan))); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); + } + + testWidgets('hidden subscription UI keeps the plain row', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic, limitSeconds: 18000), showSubscriptionUI: false)); + + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); + + testWidgets('no subscription yet keeps the plain row', (tester) async { + await pump(tester, _Usage(null)); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); +} diff --git a/app/test/widgets/settings/settings_search_header_stability_test.dart b/app/test/widgets/settings/settings_search_header_stability_test.dart index 1ed62ab23b8..77465f6b503 100644 --- a/app/test/widgets/settings/settings_search_header_stability_test.dart +++ b/app/test/widgets/settings/settings_search_header_stability_test.dart @@ -8,6 +8,7 @@ import 'package:omi/backend/preferences.dart'; import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/settings/settings_drawer.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/providers/device_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/user_provider.dart'; import 'package:omi/providers/usage_provider.dart'; @@ -18,6 +19,15 @@ import 'package:omi/utils/platform/platform_manager.dart'; // and the field sits 4 pt off the cards. Header height must remain constant (48 pt) // and search field must align with the settings cards (padding left OmiSpacing.lg = 20 pt). +/// No device connected: the Device row reads Disconnected. +class _Device extends ChangeNotifier implements DeviceProvider { + @override + bool get isConnected => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Widget _buildHarness() { return MultiProvider( providers: [ @@ -25,6 +35,7 @@ Widget _buildHarness() { ChangeNotifierProvider.value(value: HomeProvider()), ChangeNotifierProvider.value(value: UserProvider()), ChangeNotifierProvider.value(value: UsageProvider()), + ChangeNotifierProvider.value(value: _Device()), ], child: const MaterialApp( localizationsDelegates: [ diff --git a/app/test/widgets/settings/settings_sheet_appearance_test.dart b/app/test/widgets/settings/settings_sheet_appearance_test.dart index 69806bb586b..80c7adcd1a1 100644 --- a/app/test/widgets/settings/settings_sheet_appearance_test.dart +++ b/app/test/widgets/settings/settings_sheet_appearance_test.dart @@ -24,6 +24,9 @@ class _Device extends ChangeNotifier implements DeviceProvider { @override bool get isConnected => true; + @override + int get batteryLevel => -1; + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/app/test/widgets/settings/settings_structure_test.dart b/app/test/widgets/settings/settings_structure_test.dart index 2f29eb1669a..c2394e5eab1 100644 --- a/app/test/widgets/settings/settings_structure_test.dart +++ b/app/test/widgets/settings/settings_structure_test.dart @@ -30,6 +30,9 @@ class _Device extends ChangeNotifier implements DeviceProvider { @override bool get isConnected => true; + @override + int get batteryLevel => 82; + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -141,7 +144,7 @@ void main() { } testWidgets( - 'the sheet is Account, Plan, Referral, the groups, Connect an Agent, Memories, Goals and Feedback, in order, keyed', + 'the sheet is Account, Plan, the most-opened groups, Voice, Integrations, Connect an Agent, Memories, Goals, Help, Referral and Feedback, in order, keyed', ( tester, ) async { @@ -150,36 +153,38 @@ void main() { expect(rows.map(_keyOf).toList(), [ 'settings_account', 'settings_row_planAndUsage', - 'settings_row_referral', 'settings_group_device', 'settings_group_recording', - 'settings_group_voice', 'settings_group_notifications', + 'settings_group_privacy', + 'settings_group_voice', 'settings_group_integrations', 'settings_row_connect_agent', - 'settings_group_privacy', 'settings_row_memories', 'settings_row_goals', 'settings_group_help', + 'settings_row_referral', 'settings_row_feedback', // where Intercom is supported (the host test is) 'settings_group_developer', ]); // Account shows who is signed in. expect(rows.first.title, 'Ada'); expect(rows.first.subtitle, 'ada@example.com'); + // Device shows the connected device's battery without opening the group. + expect(rows[2].value, '82%'); expect(rows.skip(1).map((r) => r.title).toList(), [ en.planAndUsage, - en.referralProgram, en.device, en.recordingAndTranscription, - en.assistantVoiceSettingsTitle, en.notificationsAndDisplay, + en.dataAndPrivacy, + en.assistantVoiceSettingsTitle, en.integrations, en.connectAnAgent, - en.dataAndPrivacy, en.memories, en.goals, en.helpAndAbout, + en.referralProgram, en.feedbackBug, en.developerSettings, ]); diff --git a/app/test/widgets/task_completion_save_result_test.dart b/app/test/widgets/task_completion_save_result_test.dart index d72573b4237..d3548652db3 100644 --- a/app/test/widgets/task_completion_save_result_test.dart +++ b/app/test/widgets/task_completion_save_result_test.dart @@ -101,6 +101,10 @@ void main() { final provider = await pumpTasks(tester, accept: false); await tester.tap(find.byType(TaskCompletionMark)); + // The row shows its new state and folds away before the save. + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); expectStillOpen(tester, provider); @@ -112,6 +116,10 @@ void main() { final provider = await pumpTasks(tester, accept: false); await tester.drag(find.text('Buy milk'), const Offset(400, 0)); + // The row shows its new state and folds away before the save. + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); expectStillOpen(tester, provider); @@ -123,6 +131,10 @@ void main() { final provider = await pumpTasks(tester, accept: true); await tester.tap(find.byType(TaskCompletionMark)); + // The row shows its new state and folds away before the save. + await tester.pump(const Duration(seconds: 2)); + await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 2)); await tester.pumpAndSettle(); expect(provider.actionItems.single.completed, isTrue); diff --git a/app/test/widgets/tasks_empty_state_test.dart b/app/test/widgets/tasks_empty_state_test.dart index 8ac79bb8994..c33725a8714 100644 --- a/app/test/widgets/tasks_empty_state_test.dart +++ b/app/test/widgets/tasks_empty_state_test.dart @@ -47,16 +47,14 @@ void main() { expect(find.byKey(const ValueKey('omi.action_items.empty')), findsOneWidget); expect(title, findsOneWidget); expect(hint, findsOneWidget); - expect(icon, findsOneWidget); - expect(l10n.tasksEmptyStateMessage, 'Start a conversation to create a task.'); + expect(icon, findsNothing, reason: 'the empty state is text only'); + expect(l10n.tasksEmptyStateMessage, 'To-dos from your conversations will show up here.'); expect(create, findsNothing); expect(find.byType(OmiButton), findsNothing); expect(find.byType(FloatingActionButton), findsNothing); expect(tester.widget(title).style!.color, OmiColors.textPrimary); expect(tester.widget(hint).style!.color, OmiColors.textSecondary); - expect(IconTheme.of(tester.element(icon)).color, OmiColors.textTertiary); final emptyTitleRect = tester.getRect(title); - final emptyIconRect = tester.getRect(icon); status = 503; await provider.forceRefreshActionItems(); @@ -70,7 +68,7 @@ void main() { await tester.tap(find.text(l10n.retry)); await tester.pumpAndSettle(); expect(provider.apiViewState.phase, ApiViewPhase.empty); - expect(icon, findsOneWidget); + expect(icon, findsNothing); expect(hint, findsOneWidget); expect(create, findsNothing); expect(tester.getRect(title), emptyTitleRect); @@ -102,7 +100,7 @@ void main() { final homeTitleRect = tester.getRect(find.text(l10n.noConversationsYet)); expect(emptyTitleRect.top, homeTitleRect.top); expect(emptyTitleRect.center.dx, homeTitleRect.center.dx); - expect(emptyIconRect, tester.getRect(find.byIcon(Icons.forum_rounded))); + expect(find.byIcon(Icons.forum_rounded), findsNothing); tester.platformDispatcher.textScaleFactorTestValue = 2; await tester.pumpAndSettle(); diff --git a/app/test/widgets/tasks_more_menu_test.dart b/app/test/widgets/tasks_more_menu_test.dart new file mode 100644 index 00000000000..68480135d28 --- /dev/null +++ b/app/test/widgets/tasks_more_menu_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.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/action_item.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/action_items/widgets/tasks_more_menu.dart'; +import 'package:omi/providers/action_items_provider.dart'; +import 'package:omi/ui/ui.dart'; + +// Tasks' page actions live in one bare "⋯" beside the Home / Tasks switcher: the completed view +// and export, with no circle behind the glyph. +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + Future pump(WidgetTester tester) async { + final provider = ActionItemsProvider( + getActionItems: ( + {limit = 50, + offset = 0, + completed, + conversationId, + startDate, + endDate, + dueStartDate, + dueEndDate}) async => + const ActionItemsResponse(actionItems: [], hasMore: false), + ); + addTearDown(provider.dispose); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: Center(child: TasksMoreMenu())), + ), + ), + ); + return provider; + } + + testWidgets('the button is a bare glyph with a spoken label', (tester) async { + await pump(tester); + final button = tester.widget(find.byKey(TasksMoreMenu.buttonKey)); + expect(button.filled, isFalse); + expect(find.bySemanticsLabel('More options'), findsOneWidget); + }); + + testWidgets('the menu holds the completed view and export, even with no tasks', (tester) async { + final provider = await pump(tester); + await tester.tap(find.byKey(TasksMoreMenu.buttonKey)); + await tester.pumpAndSettle(); + expect(find.text('Show Completed'), findsOneWidget); + expect(find.text('Export'), findsOneWidget); + + await tester.tap(find.text('Show Completed')); + await tester.pumpAndSettle(); + expect(provider.showCompletedView, isTrue); + }); +} diff --git a/app/test/widgets/upgrade_paywall_test.dart b/app/test/widgets/upgrade_paywall_test.dart new file mode 100644 index 00000000000..bd0bdba4e19 --- /dev/null +++ b/app/test/widgets/upgrade_paywall_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/paywall/outline_paywall_page.dart'; +import 'package:omi/pages/settings/widgets/standalone_plans_sheet.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/paywall_experiment.dart'; + +// With the Superwall pilot off (every store build), each upgrade entry point asks the paywall +// experiment first: a variant arm gets its page, control gets the plans sheet, and the web +// variant's fallback (forceNative) always gets the sheet. + +Future _open(WidgetTester tester, {PaywallExperimentConfig? experiment, bool forceNative = false}) async { + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => UsageProvider(), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => TextButton( + onPressed: () => openUpgradePaywall( + context, + source: 'usage_page', + forceNative: forceNative, + experiment: experiment, + ), + child: const Text('upgrade'), + ), + ), + ), + ), + ); + await tester.tap(find.text('upgrade')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); +} + +void main() { + setUp(PaywallExperiment.resetForTesting); + + testWidgets('control opens the plans sheet', (tester) async { + await _open(tester, experiment: PaywallExperimentConfig.control); + expect(find.byType(StandalonePlansSheet), findsOneWidget); + expect(find.byType(OutlinePaywallPage), findsNothing); + }); + + testWidgets('a variant arm opens its paywall instead of the sheet', (tester) async { + await _open(tester, experiment: const PaywallExperimentConfig(variant: PaywallVariant.outline)); + expect(find.byType(OutlinePaywallPage), findsOneWidget); + expect(find.byType(StandalonePlansSheet), findsNothing); + }); + + testWidgets('forceNative skips the experiment and opens the sheet', (tester) async { + await _open( + tester, + experiment: const PaywallExperimentConfig(variant: PaywallVariant.outline), + forceNative: true, + ); + expect(find.byType(StandalonePlansSheet), findsOneWidget); + expect(find.byType(OutlinePaywallPage), findsNothing); + }); +} diff --git a/app/test/widgets/voice_profile_card_test.dart b/app/test/widgets/voice_profile_card_test.dart new file mode 100644 index 00000000000..b8ce3e94983 --- /dev/null +++ b/app/test/widgets/voice_profile_card_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:connectivity_plus_platform_interface/connectivity_plus_platform_interface.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/bt_device/bt_device.dart'; +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/pages/capture/widgets/widgets.dart'; +import 'package:omi/providers/device_provider.dart'; +import 'package:omi/providers/home_provider.dart'; +import 'package:omi/services/services.dart'; + +class _Device extends DeviceProvider { + _Device() : super(chargingConnectionLoader: (_) async => null) { + pairedDevice = BtDevice(id: 'p', name: 'Omi', type: DeviceType.omi, rssi: -40); + isConnected = true; + } + + @override + Future initiateConnection(String caller, {bool boundDeviceOnly = false}) async {} +} + +class _NoConnectivity extends ConnectivityPlatform { + @override + Future> checkConnectivity() async => [ConnectivityResult.none]; + + @override + Stream> get onConnectivityChanged => const Stream.empty(); +} + +// "Teach Omi your voice" shows only without a voice profile; closing it hides it for good and +// points to Settings > Voice Profile. +void main() { + setUpAll(() async { + ConnectivityPlatform.instance = _NoConnectivity(); + try { + await ServiceManager.init(); + } catch (_) { + // Already initialised by another test in this isolate. + } + }); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + }); + + Future pump(WidgetTester tester, {required bool hasProfile}) { + final home = HomeProvider()..hasSpeakerProfile = hasProfile; + return tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: home), + ChangeNotifierProvider.value(value: _Device()), + ], + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: [Locale('en')], + home: Scaffold(body: SpeechProfileCardWidget()), + ), + ), + ); + } + + testWidgets('hidden once the reader has a voice profile', (tester) async { + await pump(tester, hasProfile: true); + expect(find.text('Teach Omi your voice'), findsNothing); + }); + + testWidgets('closing it hides it for good and points to Settings', (tester) async { + await pump(tester, hasProfile: false); + expect(find.text('Teach Omi your voice'), findsOneWidget); + expect(find.bySemanticsLabel('Dismiss'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('voice_profile_card_dismiss'))); + await tester.pump(); + expect(find.text('Teach Omi your voice'), findsNothing); + expect(find.text('You can set up Voice Profile anytime in Settings.'), findsOneWidget); + expect(SharedPreferencesUtil().voiceProfileCardDismissed, isTrue); + + await tester.pumpWidget(const SizedBox()); + await pump(tester, hasProfile: false); + expect(find.text('Teach Omi your voice'), findsNothing, reason: 'stays closed'); + }); +} diff --git a/app/test/widgets/waiting_memory_preview_test.dart b/app/test/widgets/waiting_memory_preview_test.dart index 691b4245e4b..b9e1216de0a 100644 --- a/app/test/widgets/waiting_memory_preview_test.dart +++ b/app/test/widgets/waiting_memory_preview_test.dart @@ -5,7 +5,6 @@ import 'package:omi/backend/schema/memory.dart'; import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/memories/widgets/memory_item.dart'; import 'package:omi/pages/memories/widgets/memory_edit_sheet.dart'; -import 'package:omi/pages/settings/usage_page.dart'; import 'package:omi/providers/memories_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; @@ -53,7 +52,7 @@ void main() { ), )); expect(find.text('Pending'), findsOneWidget); - expect(find.text('View Usage'), findsOneWidget); + expect(find.text('Upgrade'), findsOneWidget); expect(find.byIcon(Icons.lock_outline), findsNothing); expect(find.bySemanticsLabel('OK'), findsNothing); final preview = tester.getRect(find.byType(OmiLockedPreview)); @@ -110,21 +109,18 @@ void main() { expect(editCalls, 0); expect(routes.pushed, hasLength(2)); if (visible) { - final destination = - (routes.pushed.last as MaterialPageRoute).builder(tester.element(find.byType(MemoryItem))); - expect(destination, isA()); - expect((destination as UsagePage).showUpgradeDialog, isFalse); + // The paywall (or its plans-sheet fallback) opens over the list. + expect(routes.pushed.last, isA()); expect(find.byType(MemoryEditSheet), findsNothing); expect(find.byType(SelectableText), findsNothing); expect(find.bySemanticsLabel('Private held memory'), findsNothing); - // Do not build the network-backed usage page. + // Do not build the network-backed plans sheet. await tester.pumpWidget(const SizedBox.shrink()); } else { await tester.pumpAndSettle(); expect(tester.widget(find.byType(MemoryEditSheet)).readOnly, isTrue); expect(find.widgetWithText(SelectableText, 'Private held memory'), findsOneWidget); expect(find.byKey(const ValueKey('memory_edit_field')), findsNothing); - expect(find.byType(UsagePage), findsNothing); expect(tester.takeException(), isNull); } }); diff --git a/backend/docs/feature-flag-registry.md b/backend/docs/feature-flag-registry.md index c181ffa29b4..8805a763565 100644 --- a/backend/docs/feature-flag-registry.md +++ b/backend/docs/feature-flag-registry.md @@ -110,7 +110,9 @@ entries are exempt: they are queued for removal, not running. | `SPEAKER_GROUPING_SHADOW` | dazheng | [backend/docs/listen_pusher_pipeline.mdx](../../backend/docs/listen_pusher_pipeline.mdx) | 2026-11-08 | | `exp-002-desktop-identity-v1` | unowned | [backend/docs/experiments/EXP-002-desktop-identity-memory-v1.md](../../backend/docs/experiments/EXP-002-desktop-identity-memory-v1.md) | 2026-10-26 | | `onboarding-variant` | nik | [product/experiments/onboarding-variant.md](../../product/experiments/onboarding-variant.md) | 2026-11-13 | +| `paywall-reask` | yinka | [app/lib/services/experiments/paywall_reask.md](../../app/lib/services/experiments/paywall_reask.md) | 2026-11-13 | | `paywall-variant` | nik | [product/experiments/paywall-variant.md](../../product/experiments/paywall-variant.md) | 2026-11-12 | +| `settings-plan-card` | yinka | [product/experiments/settings-plan-card.md](../../product/experiments/settings-plan-card.md) | 2026-11-15 | ## Overdue for a decision @@ -154,7 +156,9 @@ and an explicit empty literal renders as `''`. | `SPEAKER_GROUPING_SHADOW` | off\|owner\|cohort; default off; prod cached-evidence comparison at 10 percent; never changes transcript labels | backend | env | closed | — | — | cohort (backend-listen (chart), pusher (chart)); {value: 'cohort', category: rollout} (cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher) | — | pending | 2026-11-08 | dazheng | | `exp-002-desktop-identity-v1` | EXP-002 memory_v1 desktop identity arm enrollment | backend, macos | posthog | closed | — | — | — | expected (enable) | pending | 2026-10-26 | unowned | | `onboarding-variant` | Mobile first-run onboarding A/B/C after sign-in and consent: control (native steps unchanged) / reid (device-first onboarding rendered from a web page, OTA; payload names it) / yinka (onboarding ending in a first conversation, PR #21445, native). Client-evaluated multivariate flag; an unread flag, an unknown variant or a reid payload without an https URL is control; the page never owns completion | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-13 | nik | +| `paywall-reask` | Mobile paywall re-ask on app open for free readers: test shows the usual paywall (openUpgradePaywall, so paywall-variant picks the design) 2, 3, 5 then every 7 days after the last paywall seen; control shows nothing on the same schedule. Client-evaluated; no identity, unread flag or any error shows nothing | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-13 | yinka | | `paywall-variant` | Mobile paywall A/B test: control (current PlansSheet) / outline (PR #21412 soft paywall, native) / web (webview paywall whose page is the variant). Client-evaluated multivariate flag; control collapses to the shipped sheet, paid readers and App Review builds never see a variant | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-12 | nik | +| `settings-plan-card` | Mobile Settings plan section A/B for Free and Plus readers: control (Plan & Usage row with the minutes meter and a full-width Upgrade under it) / card (plan card: plan name large, minutes meter, full-width Upgrade; tapping it opens Plan & Usage). Client-evaluated multivariate flag; no identity, an unread flag or an unknown variant is control and is not counted | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-15 | yinka | ### rollout diff --git a/config/feature-flags.yaml b/config/feature-flags.yaml index 9a078d60b5e..b74df161475 100644 --- a/config/feature-flags.yaml +++ b/config/feature-flags.yaml @@ -1,4 +1,5 @@ flags: + - {"key":"settings-plan-card","prereg":"product/experiments/settings-plan-card.md","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile Settings plan section A/B for Free and Plus readers: control (Plan & Usage row with the minutes meter and a full-width Upgrade under it) / card (plan card: plan name large, minutes meter, full-width Upgrade; tapping it opens Plan & Usage). Client-evaluated multivariate flag; no identity, an unread flag or an unknown variant is control and is not counted","fail":"closed","owner":"yinka","created":"2026-10-15","review_by":"2026-11-15","decision":"pending","posthog":{"row":"expected","role":"exposure"},"notes":"Exposure is $feature_flag_called when the section is drawn for a Free or Plus reader with a PostHog-assigned arm. Debug builds: --dart-define=OMI_SETTINGS_PLAN_CARD=control|card."} - {"key":"SYNC_REPROCESS_COALESCE_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Coalesce sync append enrichment after a durable quiet window; code/dev off, Train 1 prod sync hosts on","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"pending","notes":"Train 1 only after emulator and local HTTP qualification. Off restores eager appends; existing outbox obligations still drain.","review_by":"2026-11-14"} - {"key":"SYNC_REPROCESS_SETTLE_SECONDS","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Sync append enrichment quiet window in seconds; default 600, bounded 1 to 86400","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"keep","notes":"Only used when sync reprocess coalescing is enabled. Repeated marks extend one outstanding obligation."} - {"key":"CONVERSATION_LLM_INPUT_DEDUPE_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Reuse notes and L1 for exact successful effective inputs; code/dev off, Train 1 prod sync hosts on","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"pending","notes":"Includes language, capture dates, rendered prompt context, feedback and prompt/schema implementation. Off restores feature invocations without deleting successful hashes.","review_by":"2026-11-14"} @@ -22,6 +23,7 @@ flags: - {"key":"paywall-variant","prereg":"product/experiments/paywall-variant.md","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile paywall A/B test: control (current PlansSheet) / outline (PR #21412 soft paywall, native) / web (webview paywall whose page is the variant). Client-evaluated multivariate flag; control collapses to the shipped sheet, paid readers and App Review builds never see a variant","fail":"closed","owner":"nik","created":"2026-10-13","review_by":"2026-11-12","decision":"pending","posthog":{"row":"expected","role":"exposure"},"notes":"PostHog id 957521, control 100% until the build is in the stores; the split is the experiment. Payload per variant: {\"url\": \"https://…\" (web only, https), \"onboarding_paywall\": true|false}. Debug builds: --dart-define=OMI_PAYWALL_VARIANT=outline|web [OMI_PAYWALL_WEB_URL=…]."} - {"key":"onboarding-variant","prereg":"product/experiments/onboarding-variant.md","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile first-run onboarding A/B/C after sign-in and consent: control (native steps unchanged) / reid (device-first onboarding rendered from a web page, OTA; payload names it) / yinka (onboarding ending in a first conversation, PR #21445, native). Client-evaluated multivariate flag; an unread flag, an unknown variant or a reid payload without an https URL is control; the page never owns completion","fail":"closed","owner":"nik","created":"2026-10-14","review_by":"2026-11-13","decision":"pending","posthog":{"row":"expected","role":"exposure"},"notes":"Replaces onboarding-webview (PostHog 957522, test-account only). Payload for reid: {\"url\": \"https://…\"} (https). A page that errors or never reports ready within 10s falls back to native onboarding. Debug builds: --dart-define=OMI_ONBOARDING_VARIANT=reid|yinka [OMI_ONBOARDING_WEBVIEW_URL=https://…]."} - {"key":"DIARIZATION_V2_ENABLED","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Enable the separate batch-only diarizer app; default false; startup loads models before readiness","fail":"closed","owner":"dazheng","created":"2026-10-14","decision":"keep"} + - {"key":"paywall-reask","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile paywall re-ask on app open for free readers: test shows the usual paywall (openUpgradePaywall, so paywall-variant picks the design) 2, 3, 5 then every 7 days after the last paywall seen; control shows nothing on the same schedule. Client-evaluated; no identity, unread flag or any error shows nothing","fail":"closed","owner":"yinka","created":"2026-10-14","review_by":"2026-11-13","decision":"pending","prereg":"app/lib/services/experiments/paywall_reask.md","posthog":{"row":"expected","role":"exposure"},"notes":"Not yet created in PostHog. Debug builds: --dart-define=OMI_PAYWALL_REASK=test|control [OMI_PAYWALL_REASK_NOW=true]."} - {"key":"HELD_CONTENT_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Default-off at-cap choice API; additionally requires X-Omi-Held-Content: 1. Capture custody and recovery must ship before enablement.","fail":"closed","owner":"dazheng","created":"2026-10-13","review_by":"2026-11-12","decision":"pending"} - {"key":"BILLING_WEBHOOK_LEDGER_ENABLED","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Durable Stripe webhook receipts, duplicate suppression and failed-schedule retries; default false; no deployment declaration or activation in INF sprint","fail":"closed","owner":"dazheng","created":"2026-10-13","decision":"pending"} - {"key":"mobile-plan-storefront","aliases":[],"kind":"posthog","lifecycle":"rollout","surfaces":["mobile"],"summary":"Mobile backend-driven plans sheet; default off; existing payment rails and App Review hiding unchanged","fail":"closed","owner":"dazheng","created":"2026-10-13","review_by":"2026-11-12","decision":"pending","posthog":{"row":"absent","role":"enable"}} diff --git a/contracts/analytics/events.json b/contracts/analytics/events.json index fbb597e56f7..7f79c145332 100644 --- a/contracts/analytics/events.json +++ b/contracts/analytics/events.json @@ -3648,7 +3648,8 @@ "screen_context_gate", "mcp_daily_cap", "budget_80", - "onboarding" + "onboarding", + "app_open_reask" ] }, "currentPlan": { @@ -4220,7 +4221,8 @@ "screen_context_gate", "mcp_daily_cap", "budget_80", - "onboarding" + "onboarding", + "app_open_reask" ] } }, diff --git a/product/experiments/paywall-variant.md b/product/experiments/paywall-variant.md index f3f3b9c6225..06198055481 100644 --- a/product/experiments/paywall-variant.md +++ b/product/experiments/paywall-variant.md @@ -13,6 +13,10 @@ New designs join as new arms; the split is set in PostHog, never in code. **Unit.** A free reader who is shown a paywall. Exposure = `$feature_flag_called` with `$feature_flag = paywall-variant`, emitted once per arm per app process by `PaywallRouter` at the moment a variant (or control) paywall opens. Paid readers and App Review builds never enter. +At the end-of-onboarding placement control shows nothing but is exposed at the same moment +(`PaywallRouter.exposeControl`), so both arms enter at the same point. `onboarding_paywall` must +therefore have the same value in every arm's payload, control included; a mismatch skews the +comparison. **Primary metric.** `Upgrade Succeeded` per exposed reader (funnel, 7-day window). diff --git a/product/experiments/settings-plan-card.md b/product/experiments/settings-plan-card.md new file mode 100644 index 00000000000..53f86517f77 --- /dev/null +++ b/product/experiments/settings-plan-card.md @@ -0,0 +1,33 @@ +# Pre-registration: Settings plan card A/B (`settings-plan-card`) + +**Question.** Does a plan card on the Settings sheet (the plan name large, the minutes meter and a +full-width Upgrade) raise free→paid conversion compared with the Plan & Usage row that has the +meter and Upgrade under it? Settings is opened by about 40% of active readers and most Settings +upgrades start there. + +**Arms** (PostHog multivariate flag `settings-plan-card`, 50/50, experience continuity on): +- `control`: the Plan & Usage row, then the minutes meter and a full-width Upgrade. +- `card`: a plan card with a small "Plan & Usage" label, the plan name in large type, the minutes + meter and line, then a full-width Upgrade; tapping the card outside Upgrade opens Plan & Usage. +Both arms open the same paywall (`openUpgradePaywall`, source `usage_page`), so `paywall-variant` +picks the design in either arm. + +**Unit.** A Free or Plus reader who opens Settings. Exposure = `$feature_flag_called` with +`$feature_flag = settings-plan-card`, once per identity and arm, when the section is drawn for a +Free or Plus reader. Readers on other plans, readers with subscription UI hidden, and readers whose +arm is a fallback (no identity, unread flag, unknown variant) are never counted. + +**Primary metric.** `Upgrade Succeeded` per exposed reader (funnel, 7-day window). + +**Secondary / guardrails.** `Paywall Variant Shown` with `source = usage_page` per exposed reader +(Upgrade taps); `Upgrade Plan Selected` per exposed reader; Plan & Usage page opens per exposed +reader; `Product Error` per reader. + +**Decision rule.** Minimum detectable effect 10% relative on the primary metric at PostHog's +default confidence. Run until each arm has at least 2,000 exposed readers or 14 days, whichever is +later. Ship the winner to 100% and delete the losing layout and this flag. + +**Kill switch.** Set the split to control 100% (or deactivate the flag); every build shows the +control layout without a release. + +**Owner.** yinka. Created 2026-10-15. Review by 2026-11-15.