diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-home-idle-new-recording.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-home-idle-new-recording.png new file mode 100644 index 00000000000..4ac45101369 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/001-mobile-ios-home-idle-new-recording.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-home-pill-listening.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-home-pill-listening.png new file mode 100644 index 00000000000..a45be533557 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/002-mobile-ios-home-pill-listening.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-pill-paused.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-pill-paused.png new file mode 100644 index 00000000000..a0ccb1fed79 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/003-mobile-ios-home-pill-paused.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-pill-sheet.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-pill-sheet.png new file mode 100644 index 00000000000..9fdb546b5a5 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/004-mobile-ios-home-pill-sheet.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/005-mobile-ios-home-tasks-dot.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/005-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/005-mobile-ios-home-tasks-dot.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/006-mobile-ios-chat-welcome.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/006-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/006-mobile-ios-chat-welcome.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/007-mobile-ios-upgrade-in-place.png b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/007-mobile-ios-upgrade-in-place.png new file mode 100644 index 00000000000..9221f078cd9 Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/007-mobile-ios-upgrade-in-place.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..01a51929f25 --- /dev/null +++ b/.agent-artifacts/ui-evidence/yinka-home-redesign-upgrade/evidence.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "images": [ + {"file": "001-mobile-ios-home-idle-new-recording.png", "platform": "mobile-ios", "description": "Redesigned Home (home-redesign test): no card above the list; Ask Omi beside the + New Recording pill", "captured_by": "agent", "source": "manual"}, + {"file": "002-mobile-ios-home-pill-listening.png", "platform": "mobile-ios", "description": "Recording with the phone: the pill says Listening with a red dot and Stop; still no card above the list", "captured_by": "agent", "source": "manual"}, + {"file": "003-mobile-ios-home-pill-paused.png", "platform": "mobile-ios", "description": "Paused: grey dot and the pill's one control is Resume", "captured_by": "agent", "source": "manual"}, + {"file": "004-mobile-ios-home-pill-sheet.png", "platform": "mobile-ios", "description": "Tapping a paused pill opens the details sheet: Resume, Live Transcript, Stop Recording", "captured_by": "agent", "source": "manual"}, + {"file": "005-mobile-ios-home-tasks-dot.png", "platform": "mobile-ios", "description": "Home after onboarding: the first conversation and a dot on Tasks (captured on the #21445 branch; locked row shown in its older look)", "captured_by": "agent", "source": "manual"}, + {"file": "006-mobile-ios-chat-welcome.png", "platform": "mobile-ios", "description": "Omi's first chat message in an empty chat", "captured_by": "agent", "source": "manual"}, + {"file": "007-mobile-ios-upgrade-in-place.png", "platform": "mobile-ios", "description": "An upgrade entry point opening the paywall experiment's arm in place (Outline arm forced)", "captured_by": "agent", "source": "manual"} + ] +} 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/docs/ux-contract.md b/app/docs/ux-contract.md index e0c7c3379b9..65858421108 100644 --- a/app/docs/ux-contract.md +++ b/app/docs/ux-contract.md @@ -307,6 +307,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 b3623cd135d..c4f4b2d1523 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'; @@ -16,7 +16,9 @@ import 'package:omi/pages/activity/activity_bell_button.dart'; import 'package:omi/pages/activity/activity_seen.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'; @@ -222,7 +224,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(), @@ -231,40 +244,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, { @@ -403,11 +393,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', @@ -434,9 +424,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/l10n/app_ar.arb b/app/lib/l10n/app_ar.arb index f02809db20a..90efafb924b 100644 --- a/app/lib/l10n/app_ar.arb +++ b/app/lib/l10n/app_ar.arb @@ -1516,7 +1516,7 @@ "durationSeconds": "المدة: {seconds} ثانية", "additionalSpeechSampleRemoved": "تمت إزالة عينة الكلام الإضافية", "consentDataMessage": "بالمتابعة، سيتم تخزين محادثاتك وتسجيلاتك ومعلوماتك الشخصية بشكل آمن على خوادمنا. تتم معالجة تسجيلاتك الصوتية ونصوصك بواسطة خدمات ذكاء اصطناعي تابعة لجهات خارجية (بما في ذلك Deepgram للنسخ و OpenAI للتحليل) لتزويدك برؤى مدعومة بالذكاء الاصطناعي وتمكين جميع ميزات التطبيق.", - "tasksEmptyStateMessage": "ابدأ محادثة لإنشاء مهمة.", + "tasksEmptyStateMessage": "ستظهر هنا المهام من محادثاتك.", "clearChatAction": "مسح المحادثة", "enableApps": "تفعيل التطبيقات", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "لم أطلبه بعد", "onboardingOmiOrdered": "لقد طلبته", "onboardingPermissionTapToFix": "اضغط للإصلاح", + "voiceProfileCardDismissed": "يمكنك إعداد بصمة صوتك في أي وقت من الإعدادات.", + "homeNewRecordingButton": "تسجيل جديد", + "chatWelcomeMessage": "مرحبًا، أنا Omi. اسألني عن أي شيء تحدثت عنه: ما قاله شخص ما، أو ما وعدت به، أو ملخص يومك. يمكنني أيضًا إضافة مهام وتذكّر الأشياء لك.", + "onboardingStarterTaskAsk": "اسأل Omi عن يومك", + "onboardingStarterTaskRecord": "سجّل محادثة مع Omi", + "onboardingStarterTaskCheck": "ضع علامة على هذه المهمة لإنهائها", "homePendingTitle": "محادثات في الطريق", "homePendingCatchingUp": "جارٍ اللحاق بالصوت السابق", "onTheWayTitle": "في الطريق", diff --git a/app/lib/l10n/app_be.arb b/app/lib/l10n/app_be.arb index 2507c692597..a1527278048 100644 --- a/app/lib/l10n/app_be.arb +++ b/app/lib/l10n/app_be.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Яшчэ не замовіў", "onboardingOmiOrdered": "Я ўжо замовіў", "onboardingPermissionTapToFix": "Націсніце, каб выправіць", + "voiceProfileCardDismissed": "Профіль голасу заўсёды можна наладзіць у Наладах.", + "homeNewRecordingButton": "Новы запіс", + "chatWelcomeMessage": "Прывітанне, я Omi. Пытайцеся пра ўсё, пра што вы размаўлялі: што хтосьці сказаў, што вы паабяцалі, ці як прайшоў ваш дзень. Я таксама магу дадаваць задачы і запамінаць рэчы за вас.", + "onboardingStarterTaskAsk": "Спытайце Omi пра свой дзень", + "onboardingStarterTaskRecord": "Запішыце размову з Omi", + "onboardingStarterTaskCheck": "Адзначце гэтую задачу, каб завяршыць яе", "homePendingTitle": "Размовы ў дарозе", "homePendingCatchingUp": "Апрацоўваем ранейшае аўдыя", "onTheWayTitle": "У дарозе", diff --git a/app/lib/l10n/app_bg.arb b/app/lib/l10n/app_bg.arb index 65dd854e9bd..183d0ac0e62 100644 --- a/app/lib/l10n/app_bg.arb +++ b/app/lib/l10n/app_bg.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "Продължителност: {seconds} секунди", "additionalSpeechSampleRemoved": "Допълнителната гласова проба е премахната", "consentDataMessage": "С продължаването вашите разговори, записи и лична информация ще бъдат сигурно съхранени на нашите сървъри. Вашите аудиозаписи и транскрипции се обработват от AI услуги на трети страни (включително Deepgram за транскрипция и OpenAI за анализ), за да ви предоставят прозрения, базирани на AI, и да активират всички функции на приложението.", - "tasksEmptyStateMessage": "Започнете разговор, за да създадете задача.", + "tasksEmptyStateMessage": "Задачите от разговорите ви ще се появят тук.", "clearChatAction": "Изчисти чата", "enableApps": "Активиране на приложения", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Още не съм поръчал", "onboardingOmiOrdered": "Поръчал съм го", "onboardingPermissionTapToFix": "Докоснете, за да поправите", + "voiceProfileCardDismissed": "Можете да настроите гласовия профил по всяко време от Настройки.", + "homeNewRecordingButton": "Нов запис", + "chatWelcomeMessage": "Здравей, аз съм Omi. Питай ме за всичко, за което си говорил: какво е казал някой, какво си обещал или обобщение на деня ти. Мога и да добавям задачи и да запомням неща вместо теб.", + "onboardingStarterTaskAsk": "Попитай Omi за деня си", + "onboardingStarterTaskRecord": "Запиши разговор с Omi", + "onboardingStarterTaskCheck": "Отметни тази задача, за да я завършиш", "homePendingTitle": "Разговори на път", "homePendingCatchingUp": "Наваксваме по-ранното аудио", "onTheWayTitle": "На път", diff --git a/app/lib/l10n/app_bn.arb b/app/lib/l10n/app_bn.arb index 0a4501d7c7e..130883f8a17 100644 --- a/app/lib/l10n/app_bn.arb +++ b/app/lib/l10n/app_bn.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "এখনও অর্ডার করিনি", "onboardingOmiOrdered": "আমি অর্ডার করেছি", "onboardingPermissionTapToFix": "ঠিক করতে ট্যাপ করুন", + "voiceProfileCardDismissed": "সেটিংসে যেকোনো সময় ভয়েস প্রোফাইল সেট করতে পারেন।", + "homeNewRecordingButton": "নতুন রেকর্ডিং", + "chatWelcomeMessage": "হাই, আমি Omi। আপনি যা নিয়ে কথা বলেছেন তার যেকোনো কিছু জিজ্ঞেস করুন: কেউ কী বলেছে, আপনি কী প্রতিশ্রুতি দিয়েছেন, বা আপনার দিনের সারাংশ। আমি কাজ যোগ করতে এবং আপনার জন্য জিনিস মনে রাখতেও পারি।", + "onboardingStarterTaskAsk": "Omi-কে আপনার দিন সম্পর্কে জিজ্ঞেস করুন", + "onboardingStarterTaskRecord": "Omi দিয়ে একটি কথোপকথন রেকর্ড করুন", + "onboardingStarterTaskCheck": "শেষ করতে এই কাজটিতে টিক দিন", "homePendingTitle": "কথোপকথন আসছে", "homePendingCatchingUp": "আগের অডিও প্রক্রিয়া করা হচ্ছে", "onTheWayTitle": "আসছে", diff --git a/app/lib/l10n/app_bs.arb b/app/lib/l10n/app_bs.arb index 008d0b3ee59..ba36553bf53 100644 --- a/app/lib/l10n/app_bs.arb +++ b/app/lib/l10n/app_bs.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Još nisam naručio/la", "onboardingOmiOrdered": "Naručio/la sam ga", "onboardingPermissionTapToFix": "Dodirnite da popravite", + "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š", "homePendingTitle": "Razgovori stižu", "homePendingCatchingUp": "Obrađujemo raniji zvuk", "onTheWayTitle": "Na putu", diff --git a/app/lib/l10n/app_ca.arb b/app/lib/l10n/app_ca.arb index 464e74910e2..89bdd06cea7 100644 --- a/app/lib/l10n/app_ca.arb +++ b/app/lib/l10n/app_ca.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Encara no l'he demanat", "onboardingOmiOrdered": "Ja l'he demanat", "onboardingPermissionTapToFix": "Toca per solucionar-ho", + "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", "homePendingTitle": "Converses en camí", "homePendingCatchingUp": "Posant al dia l'àudio anterior", "onTheWayTitle": "En camí", diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 9e1c19090b1..8bf71dd5e49 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Ještě neobjednáno", "onboardingOmiOrdered": "Už jsem ho objednal/a", "onboardingPermissionTapToFix": "Klepnutím opravíte", + "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", "homePendingTitle": "Konverzace na cestě", "homePendingCatchingUp": "Zpracováváme dřívější zvuk", "onTheWayTitle": "Na cestě", diff --git a/app/lib/l10n/app_da.arb b/app/lib/l10n/app_da.arb index 68aac0d1869..17c70d896e2 100644 --- a/app/lib/l10n/app_da.arb +++ b/app/lib/l10n/app_da.arb @@ -1505,7 +1505,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", @@ -4171,6 +4171,12 @@ "onboardingOmiNotOrdered": "Ikke bestilt endnu", "onboardingOmiOrdered": "Jeg har bestilt den", "onboardingPermissionTapToFix": "Tryk for at rette", + "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", "homePendingTitle": "Samtaler på vej", "homePendingCatchingUp": "Indhenter tidligere lyd", "onTheWayTitle": "På vej", diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 405309a2c1b..09d8a94bcb4 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Noch nicht bestellt", "onboardingOmiOrdered": "Ich habe es bestellt", "onboardingPermissionTapToFix": "Zum Beheben tippen", + "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", "homePendingTitle": "Gespräche unterwegs", "homePendingCatchingUp": "Frühere Aufnahmen werden aufgeholt", "onTheWayTitle": "Unterwegs", diff --git a/app/lib/l10n/app_el.arb b/app/lib/l10n/app_el.arb index 104a738d892..38e5ef7bc56 100644 --- a/app/lib/l10n/app_el.arb +++ b/app/lib/l10n/app_el.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "Διάρκεια: {seconds} δευτερόλεπτα", "additionalSpeechSampleRemoved": "Το πρόσθετο δείγμα ομιλίας αφαιρέθηκε", "consentDataMessage": "Συνεχίζοντας, οι συνομιλίες, οι εγγραφές και τα προσωπικά σας στοιχεία θα αποθηκευτούν με ασφάλεια στους διακομιστές μας. Οι ηχογραφήσεις και τα μεταγραφές σας επεξεργάζονται από υπηρεσίες τεχνητής νοημοσύνης τρίτων (συμπεριλαμβανομένων των Deepgram για μεταγραφή και OpenAI για ανάλυση) για να σας παρέχουν γνώσεις βασισμένες σε AI και να ενεργοποιήσουν όλες τις λειτουργίες της εφαρμογής.", - "tasksEmptyStateMessage": "Ξεκινήστε μια συνομιλία για να δημιουργήσετε μια εργασία.", + "tasksEmptyStateMessage": "Οι εργασίες από τις συζητήσεις σας θα εμφανίζονται εδώ.", "clearChatAction": "Διαγραφή συνομιλίας", "enableApps": "Ενεργοποίηση εφαρμογών", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Δεν το έχω παραγγείλει ακόμα", "onboardingOmiOrdered": "Το έχω παραγγείλει", "onboardingPermissionTapToFix": "Πατήστε για διόρθωση", + "voiceProfileCardDismissed": "Μπορείτε να ρυθμίσετε το προφίλ φωνής ανά πάσα στιγμή από τις Ρυθμίσεις.", + "homeNewRecordingButton": "Νέα ηχογράφηση", + "chatWelcomeMessage": "Γεια, είμαι το Omi. Ρώτα με οτιδήποτε έχεις συζητήσει: τι είπε κάποιος, τι υποσχέθηκες ή μια σύνοψη της μέρας σου. Μπορώ επίσης να προσθέτω εργασίες και να θυμάμαι πράγματα για σένα.", + "onboardingStarterTaskAsk": "Ρώτα το Omi για τη μέρα σου", + "onboardingStarterTaskRecord": "Ηχογράφησε μια συζήτηση με το Omi", + "onboardingStarterTaskCheck": "Σημείωσε αυτή την εργασία για να την ολοκληρώσεις", "homePendingTitle": "Συνομιλίες καθ' οδόν", "homePendingCatchingUp": "Επεξεργασία προηγούμενου ήχου", "onTheWayTitle": "Καθ' οδόν", diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index b0b3a1e8f2f..2f2f9c3baf0 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -5871,7 +5871,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." }, @@ -16234,6 +16234,30 @@ "@onboardingPermissionTapToFix": { "description": "Onboarding permissions: shown on a denied permission card; tapping opens Settings" }, + "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" + }, "homePendingTitle": "Conversations on the way", "@homePendingTitle": { "description": "Home row title while recordings are still turning into conversations" diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 898ac7492ae..b2607e40cde 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Aún no lo he pedido", "onboardingOmiOrdered": "Ya lo pedí", "onboardingPermissionTapToFix": "Toca para solucionarlo", + "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", "homePendingTitle": "Conversaciones en camino", "homePendingCatchingUp": "Poniéndonos al día con el audio anterior", "onTheWayTitle": "En camino", diff --git a/app/lib/l10n/app_et.arb b/app/lib/l10n/app_et.arb index 539321c1d53..d45a112674b 100644 --- a/app/lib/l10n/app_et.arb +++ b/app/lib/l10n/app_et.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Pole veel tellinud", "onboardingOmiOrdered": "Olen selle tellinud", "onboardingPermissionTapToFix": "Puuduta parandamiseks", + "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", "homePendingTitle": "Vestlused teel", "homePendingCatchingUp": "Varasema heli töötlemine", "onTheWayTitle": "Teel", diff --git a/app/lib/l10n/app_fa.arb b/app/lib/l10n/app_fa.arb index f373bd34b53..58ecb0b5362 100644 --- a/app/lib/l10n/app_fa.arb +++ b/app/lib/l10n/app_fa.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "هنوز سفارش نداده‌ام", "onboardingOmiOrdered": "سفارش داده‌ام", "onboardingPermissionTapToFix": "برای رفع ضربه بزنید", + "voiceProfileCardDismissed": "هر زمان می‌توانید نمایه صدا را در تنظیمات راه‌اندازی کنید.", + "homeNewRecordingButton": "ضبط جدید", + "chatWelcomeMessage": "سلام، من Omi هستم. هر چیزی درباره صحبت‌هایت بپرس: اینکه کسی چه گفت، چه قولی دادی یا خلاصه‌ای از روزت. می‌توانم کار هم اضافه کنم و چیزها را برایت به خاطر بسپارم.", + "onboardingStarterTaskAsk": "از Omi درباره روزت بپرس", + "onboardingStarterTaskRecord": "یک گفتگو را با Omi ضبط کن", + "onboardingStarterTaskCheck": "این کار را تیک بزن تا تمام شود", "homePendingTitle": "گفتگوها در راه است", "homePendingCatchingUp": "در حال پردازش صدای قبلی", "onTheWayTitle": "در راه", diff --git a/app/lib/l10n/app_fi.arb b/app/lib/l10n/app_fi.arb index 71136088aec..640729923e9 100644 --- a/app/lib/l10n/app_fi.arb +++ b/app/lib/l10n/app_fi.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "En ole vielä tilannut", "onboardingOmiOrdered": "Olen tilannut sen", "onboardingPermissionTapToFix": "Korjaa napauttamalla", + "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", "homePendingTitle": "Keskusteluja tulossa", "homePendingCatchingUp": "Käsitellään aiempaa ääntä", "onTheWayTitle": "Tulossa", diff --git a/app/lib/l10n/app_fr.arb b/app/lib/l10n/app_fr.arb index 69d80674cf5..591e2d142c9 100644 --- a/app/lib/l10n/app_fr.arb +++ b/app/lib/l10n/app_fr.arb @@ -1505,7 +1505,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", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Pas encore commandé", "onboardingOmiOrdered": "Je l'ai commandé", "onboardingPermissionTapToFix": "Touchez pour corriger", + "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", "homePendingTitle": "Conversations en route", "homePendingCatchingUp": "Rattrapage de l'audio précédent", "onTheWayTitle": "En route", diff --git a/app/lib/l10n/app_he.arb b/app/lib/l10n/app_he.arb index d53eaaae006..4675bf7e2e1 100644 --- a/app/lib/l10n/app_he.arb +++ b/app/lib/l10n/app_he.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "עוד לא הזמנתי", "onboardingOmiOrdered": "הזמנתי", "onboardingPermissionTapToFix": "הקישו לתיקון", + "voiceProfileCardDismissed": "אפשר להגדיר פרופיל קולי בכל עת בהגדרות.", + "homeNewRecordingButton": "הקלטה חדשה", + "chatWelcomeMessage": "היי, אני Omi. שאל אותי כל דבר על מה שדיברת: מה מישהו אמר, מה הבטחת או סיכום של היום שלך. אני יכול גם להוסיף משימות ולזכור דברים בשבילך.", + "onboardingStarterTaskAsk": "שאל את Omi על היום שלך", + "onboardingStarterTaskRecord": "הקלט שיחה עם Omi", + "onboardingStarterTaskCheck": "סמן משימה זו כדי לסיים אותה", "homePendingTitle": "שיחות בדרך", "homePendingCatchingUp": "משלימים אודיו קודם", "onTheWayTitle": "בדרך", diff --git a/app/lib/l10n/app_hi.arb b/app/lib/l10n/app_hi.arb index 02219a9798a..ab4ace3c30a 100644 --- a/app/lib/l10n/app_hi.arb +++ b/app/lib/l10n/app_hi.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "अवधि: {seconds} सेकंड", "additionalSpeechSampleRemoved": "अतिरिक्त भाषण नमूना हटाया गया", "consentDataMessage": "जारी रखने पर, आपकी बातचीत, रिकॉर्डिंग और व्यक्तिगत जानकारी हमारे सर्वर पर सुरक्षित रूप से संग्रहीत की जाएगी। आपकी ऑडियो रिकॉर्डिंग और ट्रांसक्रिप्ट तीसरे पक्ष की AI सेवाओं (ट्रांसक्रिप्शन के लिए Deepgram और विश्लेषण के लिए OpenAI सहित) द्वारा संसाधित किए जाते हैं ताकि आपको AI-संचालित अंतर्दृष्टि प्रदान की जा सके और सभी ऐप सुविधाएँ सक्षम की जा सकें।", - "tasksEmptyStateMessage": "टास्क बनाने के लिए बातचीत शुरू करें।", + "tasksEmptyStateMessage": "आपकी बातचीत से निकले काम यहाँ दिखेंगे।", "clearChatAction": "चैट साफ़ करें", "enableApps": "ऐप्स सक्षम करें", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "अभी ऑर्डर नहीं किया", "onboardingOmiOrdered": "मैंने ऑर्डर कर दिया है", "onboardingPermissionTapToFix": "ठीक करने के लिए टैप करें", + "voiceProfileCardDismissed": "आप सेटिंग्स में कभी भी वॉइस प्रोफ़ाइल सेट कर सकते हैं।", + "homeNewRecordingButton": "नई रिकॉर्डिंग", + "chatWelcomeMessage": "नमस्ते, मैं Omi हूँ। आपने जिस बारे में बात की है, उसके बारे में कुछ भी पूछें: किसी ने क्या कहा, आपने क्या वादा किया, या आपके दिन का सारांश। मैं काम जोड़ सकता हूँ और आपके लिए चीज़ें याद भी रख सकता हूँ।", + "onboardingStarterTaskAsk": "Omi से अपने दिन के बारे में पूछें", + "onboardingStarterTaskRecord": "Omi से एक बातचीत रिकॉर्ड करें", + "onboardingStarterTaskCheck": "इस काम को पूरा करने के लिए टिक करें", "homePendingTitle": "बातचीत आ रही हैं", "homePendingCatchingUp": "पहले का ऑडियो प्रोसेस हो रहा है", "onTheWayTitle": "रास्ते में", diff --git a/app/lib/l10n/app_hr.arb b/app/lib/l10n/app_hr.arb index 6fde6deffa9..505cbc4825c 100644 --- a/app/lib/l10n/app_hr.arb +++ b/app/lib/l10n/app_hr.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Još nisam naručio/la", "onboardingOmiOrdered": "Naručio/la sam ga", "onboardingPermissionTapToFix": "Dodirnite za popravak", + "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š", "homePendingTitle": "Razgovori stižu", "homePendingCatchingUp": "Obrađujemo raniji zvuk", "onTheWayTitle": "Na putu", diff --git a/app/lib/l10n/app_hu.arb b/app/lib/l10n/app_hu.arb index 204a54b6b3f..9f5036d8ff7 100644 --- a/app/lib/l10n/app_hu.arb +++ b/app/lib/l10n/app_hu.arb @@ -1505,7 +1505,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", @@ -4225,6 +4225,12 @@ "onboardingOmiNotOrdered": "Még nem rendeltem", "onboardingOmiOrdered": "Már megrendeltem", "onboardingPermissionTapToFix": "Koppints a javításhoz", + "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", "homePendingTitle": "Beszélgetések úton", "homePendingCatchingUp": "Korábbi hang feldolgozása", "onTheWayTitle": "Úton", diff --git a/app/lib/l10n/app_id.arb b/app/lib/l10n/app_id.arb index 813439cc1c2..9d00094c494 100644 --- a/app/lib/l10n/app_id.arb +++ b/app/lib/l10n/app_id.arb @@ -1505,7 +1505,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", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Belum memesan", "onboardingOmiOrdered": "Saya sudah memesannya", "onboardingPermissionTapToFix": "Ketuk untuk memperbaiki", + "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", "homePendingTitle": "Percakapan sedang diproses", "homePendingCatchingUp": "Mengejar audio sebelumnya", "onTheWayTitle": "Dalam perjalanan", diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index d24af5ac73d..d7a3e55d72b 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Non ancora ordinato", "onboardingOmiOrdered": "L'ho ordinato", "onboardingPermissionTapToFix": "Tocca per risolvere", + "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", "homePendingTitle": "Conversazioni in arrivo", "homePendingCatchingUp": "Recupero dell'audio precedente", "onTheWayTitle": "In arrivo", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index 7211858edcd..b9d298b7487 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -1506,7 +1506,7 @@ "durationSeconds": "長さ: {seconds} 秒", "additionalSpeechSampleRemoved": "追加の音声サンプルを削除しました", "consentDataMessage": "続行することで、会話、録音、個人情報は安全に当社のサーバーに保存されます。音声録音と文字起こしは、サードパーティのAIサービス(文字起こし用のDeepgramと分析用のOpenAIを含む)によって処理され、AI駆動のインサイトを提供し、すべてのアプリ機能を有効にします。", - "tasksEmptyStateMessage": "会話を始めてタスクを作成しましょう。", + "tasksEmptyStateMessage": "会話から見つかったToDoがここに表示されます。", "clearChatAction": "チャットを消去", "enableApps": "アプリを有効化", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "まだ注文していません", "onboardingOmiOrdered": "注文済みです", "onboardingPermissionTapToFix": "タップして修正", + "voiceProfileCardDismissed": "ボイスプロフィールは設定からいつでも設定できます。", + "homeNewRecordingButton": "新規録音", + "chatWelcomeMessage": "こんにちは、Omiです。話した内容なら何でも聞いてください。誰が何を言ったか、何を約束したか、一日のまとめなど。タスクの追加や、覚えておくこともできます。", + "onboardingStarterTaskAsk": "Omiに今日のことを聞いてみる", + "onboardingStarterTaskRecord": "Omiで会話を録音する", + "onboardingStarterTaskCheck": "このタスクにチェックして完了する", "homePendingTitle": "会話を準備中", "homePendingCatchingUp": "以前の音声を処理中", "onTheWayTitle": "準備中", diff --git a/app/lib/l10n/app_kn.arb b/app/lib/l10n/app_kn.arb index d23b5265a78..23edb813d0c 100644 --- a/app/lib/l10n/app_kn.arb +++ b/app/lib/l10n/app_kn.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "ಇನ್ನೂ ಆರ್ಡರ್ ಮಾಡಿಲ್ಲ", "onboardingOmiOrdered": "ನಾನು ಆರ್ಡರ್ ಮಾಡಿದ್ದೇನೆ", "onboardingPermissionTapToFix": "ಸರಿಪಡಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ", + "voiceProfileCardDismissed": "ಸೆಟ್ಟಿಂಗ್‌ಗಳಲ್ಲಿ ಯಾವಾಗ ಬೇಕಾದರೂ ಧ್ವನಿ ಪ್ರೊಫೈಲ್ ಹೊಂದಿಸಬಹುದು.", + "homeNewRecordingButton": "ಹೊಸ ರೆಕಾರ್ಡಿಂಗ್", + "chatWelcomeMessage": "ನಮಸ್ಕಾರ, ನಾನು Omi. ನೀವು ಮಾತನಾಡಿದ ಯಾವುದರ ಬಗ್ಗೆಯೂ ಕೇಳಿ: ಯಾರು ಏನು ಹೇಳಿದರು, ನೀವು ಏನು ಭರವಸೆ ನೀಡಿದ್ದೀರಿ ಅಥವಾ ನಿಮ್ಮ ದಿನದ ಸಾರಾಂಶ. ನಾನು ಕಾರ್ಯಗಳನ್ನು ಸೇರಿಸಬಹುದು ಮತ್ತು ನಿಮಗಾಗಿ ವಿಷಯಗಳನ್ನು ನೆನಪಿಟ್ಟುಕೊಳ್ಳಬಹುದು.", + "onboardingStarterTaskAsk": "ನಿಮ್ಮ ದಿನದ ಬಗ್ಗೆ Omi ಅನ್ನು ಕೇಳಿ", + "onboardingStarterTaskRecord": "Omi ಮೂಲಕ ಒಂದು ಸಂಭಾಷಣೆಯನ್ನು ರೆಕಾರ್ಡ್ ಮಾಡಿ", + "onboardingStarterTaskCheck": "ಮುಗಿಸಲು ಈ ಕಾರ್ಯವನ್ನು ಟಿಕ್ ಮಾಡಿ", "homePendingTitle": "ಸಂಭಾಷಣೆಗಳು ಬರುತ್ತಿವೆ", "homePendingCatchingUp": "ಹಿಂದಿನ ಆಡಿಯೊವನ್ನು ಪ್ರಕ್ರಿಯೆಗೊಳಿಸಲಾಗುತ್ತಿದೆ", "onTheWayTitle": "ದಾರಿಯಲ್ಲಿದೆ", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 11fd22fadac..1f86766aa27 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "길이: {seconds}초", "additionalSpeechSampleRemoved": "추가 음성 샘플이 삭제되었습니다", "consentDataMessage": "계속하면 대화, 녹음 및 개인 정보가 서버에 안전하게 저장됩니다. 오디오 녹음 및 텍스트 변환은 제3자 AI 서비스(전사를 위한 Deepgram 및 분석을 위한 OpenAI 포함)에 의해 처리되어 AI 기반 인사이트를 제공하고 모든 앱 기능을 활성화합니다.", - "tasksEmptyStateMessage": "대화를 시작하여 할 일을 만드세요.", + "tasksEmptyStateMessage": "대화에서 나온 할 일이 여기에 표시돼요.", "clearChatAction": "채팅 삭제", "enableApps": "앱 활성화", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "아직 주문하지 않았어요", "onboardingOmiOrdered": "주문했어요", "onboardingPermissionTapToFix": "탭하여 해결", + "voiceProfileCardDismissed": "음성 프로필은 설정에서 언제든지 설정할 수 있어요.", + "homeNewRecordingButton": "새 녹음", + "chatWelcomeMessage": "안녕하세요, Omi예요. 나눈 대화라면 무엇이든 물어보세요. 누가 무슨 말을 했는지, 무엇을 약속했는지, 하루 요약까지요. 할 일을 추가하고 기억해 둘 수도 있어요.", + "onboardingStarterTaskAsk": "Omi에게 오늘 하루에 대해 물어보기", + "onboardingStarterTaskRecord": "Omi로 대화 녹음하기", + "onboardingStarterTaskCheck": "이 할 일을 체크해서 완료하기", "homePendingTitle": "대화 준비 중", "homePendingCatchingUp": "이전 오디오 처리 중", "onTheWayTitle": "준비 중", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index fef94558d0e..83b9f4ed10c 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -9330,7 +9330,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 @@ -23961,6 +23961,42 @@ abstract class AppLocalizations { /// **'Tap to fix'** String get onboardingPermissionTapToFix; + /// 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; + /// Home row title while recordings are still turning into conversations /// /// In en, this message translates to: diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index d6cc28a47f3..9f382bca58b 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -4873,7 +4873,7 @@ class AppLocalizationsAr extends AppLocalizations { 'بالمتابعة، سيتم تخزين محادثاتك وتسجيلاتك ومعلوماتك الشخصية بشكل آمن على خوادمنا. تتم معالجة تسجيلاتك الصوتية ونصوصك بواسطة خدمات ذكاء اصطناعي تابعة لجهات خارجية (بما في ذلك Deepgram للنسخ و OpenAI للتحليل) لتزويدك برؤى مدعومة بالذكاء الاصطناعي وتمكين جميع ميزات التطبيق.'; @override - String get tasksEmptyStateMessage => 'ابدأ محادثة لإنشاء مهمة.'; + String get tasksEmptyStateMessage => 'ستظهر هنا المهام من محادثاتك.'; @override String get clearChatAction => 'مسح المحادثة'; @@ -13394,6 +13394,25 @@ class AppLocalizationsAr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'اضغط للإصلاح'; + @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 => 'ضع علامة على هذه المهمة لإنهائها'; + @override String get homePendingTitle => 'محادثات في الطريق'; diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index 4c108be1b1d..a436817284f 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -4923,7 +4923,7 @@ class AppLocalizationsBe extends AppLocalizations { 'Працягваючы, вашы размовы, запісы і асабістая інфармацыя будуць надзейна захоўвацца на нашых серверах. Вашы аўдыязапісы і транскрыпцыі апрацоўваюцца староннімі сэрвісамі ШІ (уключаючы Deepgram для транскрыпцыі і OpenAI для аналізу), каб забяспечыць вас аналітыкай на аснове ШІ і ўключыць усе функцыі праграмы.'; @override - String get tasksEmptyStateMessage => 'Пачніце размову, каб стварыць задачу.'; + String get tasksEmptyStateMessage => 'Справы з вашых размоў з\'явяцца тут.'; @override String get clearChatAction => 'Очыстіць Чат'; @@ -13502,6 +13502,25 @@ class AppLocalizationsBe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Націсніце, каб выправіць'; + @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 => 'Адзначце гэтую задачу, каб завяршыць яе'; + @override String get homePendingTitle => 'Размовы ў дарозе'; diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index f5f4e622a46..4fb323c1c75 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -4928,7 +4928,7 @@ class AppLocalizationsBg extends AppLocalizations { 'С продължаването вашите разговори, записи и лична информация ще бъдат сигурно съхранени на нашите сървъри. Вашите аудиозаписи и транскрипции се обработват от AI услуги на трети страни (включително Deepgram за транскрипция и OpenAI за анализ), за да ви предоставят прозрения, базирани на AI, и да активират всички функции на приложението.'; @override - String get tasksEmptyStateMessage => 'Започнете разговор, за да създадете задача.'; + String get tasksEmptyStateMessage => 'Задачите от разговорите ви ще се появят тук.'; @override String get clearChatAction => 'Изчисти чата'; @@ -13515,6 +13515,25 @@ class AppLocalizationsBg extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Докоснете, за да поправите'; + @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 => 'Отметни тази задача, за да я завършиш'; + @override String get homePendingTitle => 'Разговори на път'; diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index d6c9335b519..24b6381c003 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -4917,7 +4917,7 @@ class AppLocalizationsBn extends AppLocalizations { 'চালিয়ে যাওয়ার মাধ্যমে, আপনার কথোপকথন, রেকর্ডিং এবং ব্যক্তিগত তথ্য আমাদের সার্ভারে নিরাপদে সংরক্ষণ করা হবে। আপনার অডিও রেকর্ডিং এবং ট্রান্সক্রিপ্ট তৃতীয় পক্ষের AI পরিষেবা দ্বারা প্রক্রিয়া করা হয় (ট্রান্সক্রিপশনের জন্য Deepgram এবং বিশ্লেষণের জন্য OpenAI সহ) যাতে আপনাকে AI-চালিত অন্তর্দৃষ্টি প্রদান করা যায় এবং সমস্ত অ্যাপ বৈশিষ্ট্য সক্ষম করা যায়।'; @override - String get tasksEmptyStateMessage => 'একটি কাজ তৈরি করতে কথোপকথন শুরু করুন।'; + String get tasksEmptyStateMessage => 'আপনার কথোপকথনের করণীয়গুলো এখানে দেখা যাবে।'; @override String get clearChatAction => 'চ্যাট সাফ করুন'; @@ -13464,6 +13464,25 @@ class AppLocalizationsBn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ঠিক করতে ট্যাপ করুন'; + @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 => 'শেষ করতে এই কাজটিতে টিক দিন'; + @override String get homePendingTitle => 'কথোপকথন আসছে'; diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index d5bd6a15296..e95b16ed2c2 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -4919,7 +4919,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'; @@ -13497,6 +13497,25 @@ class AppLocalizationsBs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dodirnite da popravite'; + @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š'; + @override String get homePendingTitle => 'Razgovori stižu'; diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index a89e97f939e..a091f10c615 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -4940,7 +4940,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'; @@ -13536,6 +13536,25 @@ class AppLocalizationsCa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toca per solucionar-ho'; + @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'; + @override String get homePendingTitle => 'Converses en camí'; diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index fa8b83fafac..dda02c32603 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -4903,7 +4903,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'; @@ -13469,6 +13469,25 @@ class AppLocalizationsCs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Klepnutím opravíte'; + @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'; + @override String get homePendingTitle => 'Konverzace na cestě'; diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index 7d93303592f..15d9793173e 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -4898,7 +4898,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'; @@ -13454,6 +13454,25 @@ class AppLocalizationsDa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tryk for at rette'; + @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'; + @override String get homePendingTitle => 'Samtaler på vej'; diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 635ecec32e9..ddd8295a857 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -4949,7 +4949,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'; @@ -13566,6 +13566,25 @@ class AppLocalizationsDe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Zum Beheben tippen'; + @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'; + @override String get homePendingTitle => 'Gespräche unterwegs'; diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 3724c56c378..2520f1c0dab 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -4950,7 +4950,7 @@ class AppLocalizationsEl extends AppLocalizations { 'Συνεχίζοντας, οι συνομιλίες, οι εγγραφές και τα προσωπικά σας στοιχεία θα αποθηκευτούν με ασφάλεια στους διακομιστές μας. Οι ηχογραφήσεις και τα μεταγραφές σας επεξεργάζονται από υπηρεσίες τεχνητής νοημοσύνης τρίτων (συμπεριλαμβανομένων των Deepgram για μεταγραφή και OpenAI για ανάλυση) για να σας παρέχουν γνώσεις βασισμένες σε AI και να ενεργοποιήσουν όλες τις λειτουργίες της εφαρμογής.'; @override - String get tasksEmptyStateMessage => 'Ξεκινήστε μια συνομιλία για να δημιουργήσετε μια εργασία.'; + String get tasksEmptyStateMessage => 'Οι εργασίες από τις συζητήσεις σας θα εμφανίζονται εδώ.'; @override String get clearChatAction => 'Διαγραφή συνομιλίας'; @@ -13551,6 +13551,25 @@ class AppLocalizationsEl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Πατήστε για διόρθωση'; + @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 => 'Σημείωσε αυτή την εργασία για να την ολοκληρώσεις'; + @override String get homePendingTitle => 'Συνομιλίες καθ\' οδόν'; diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 7477f66bb56..0077fc21922 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -4909,7 +4909,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'; @@ -13452,6 +13452,25 @@ class AppLocalizationsEn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tap to fix'; + @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'; + @override String get homePendingTitle => 'Conversations on the way'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 5ea7eb7c90e..a763702dc41 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -4915,7 +4915,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'; @@ -13507,6 +13507,25 @@ class AppLocalizationsEs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toca para solucionarlo'; + @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'; + @override String get homePendingTitle => 'Conversaciones en camino'; diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 31abd3b96a7..4a9ab19902d 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -4907,7 +4907,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'; @@ -13461,6 +13461,25 @@ class AppLocalizationsEt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Puuduta parandamiseks'; + @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'; + @override String get homePendingTitle => 'Vestlused teel'; diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index 5378aab64d3..a04c57785f6 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -4910,7 +4910,7 @@ class AppLocalizationsFa extends AppLocalizations { 'با ادامه دادن، مکالمات، ضبط‌ها و اطلاعات شخصی شما به طور ایمن در سرورهای ما ذخیره می‌شود. ضبط‌های صوتی و رونوشت‌های شما توسط سرویس‌های هوش مصنوعی شخص ثالث (از جمله Deepgram برای رونویسی و OpenAI برای تحلیل) پردازش می‌شوند تا بینش‌های مبتنی بر هوش مصنوعی را به شما ارائه دهند و تمام ویژگی‌های برنامه را فعال کنند.'; @override - String get tasksEmptyStateMessage => 'برای ایجاد یک کار، گفتگو را شروع کنید.'; + String get tasksEmptyStateMessage => 'کارهای گفتگوهای شما اینجا نمایش داده می‌شوند.'; @override String get clearChatAction => 'پاک‌کردن چت'; @@ -13462,6 +13462,25 @@ class AppLocalizationsFa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'برای رفع ضربه بزنید'; + @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 => 'این کار را تیک بزن تا تمام شود'; + @override String get homePendingTitle => 'گفتگوها در راه است'; diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 20be932c30b..4ce5e74475b 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -4912,7 +4912,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'; @@ -13472,6 +13472,25 @@ class AppLocalizationsFi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Korjaa napauttamalla'; + @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'; + @override String get homePendingTitle => 'Keskusteluja tulossa'; diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 18e064977b2..c4fe3a79919 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -4960,7 +4960,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'; @@ -13573,6 +13573,25 @@ class AppLocalizationsFr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Touchez pour corriger'; + @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'; + @override String get homePendingTitle => 'Conversations en route'; diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index f5fa73bcee2..9ed90ad7685 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -4872,7 +4872,7 @@ class AppLocalizationsHe extends AppLocalizations { 'בהמשך, השיחות, ההקלטות והמידע האישי שלך יאוחסנו בצורה מאובטחת בשרתים שלנו. הקלטות האודיו והתמלולים שלך מעובדים על ידי שירותי AI של צד שלישי (כולל Deepgram לתמלול ו-OpenAI לניתוח) כדי לספק לך תובנות מבוססות AI ולאפשר את כל תכונות האפליקציה.'; @override - String get tasksEmptyStateMessage => 'התחילו שיחה כדי ליצור משימה.'; + String get tasksEmptyStateMessage => 'משימות מהשיחות שלך יופיעו כאן.'; @override String get clearChatAction => 'נקה שיחה'; @@ -13374,6 +13374,25 @@ class AppLocalizationsHe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'הקישו לתיקון'; + @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 => 'סמן משימה זו כדי לסיים אותה'; + @override String get homePendingTitle => 'שיחות בדרך'; diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index 68ce179f8c6..92f1ecc5abf 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -4887,7 +4887,7 @@ class AppLocalizationsHi extends AppLocalizations { 'जारी रखने पर, आपकी बातचीत, रिकॉर्डिंग और व्यक्तिगत जानकारी हमारे सर्वर पर सुरक्षित रूप से संग्रहीत की जाएगी। आपकी ऑडियो रिकॉर्डिंग और ट्रांसक्रिप्ट तीसरे पक्ष की AI सेवाओं (ट्रांसक्रिप्शन के लिए Deepgram और विश्लेषण के लिए OpenAI सहित) द्वारा संसाधित किए जाते हैं ताकि आपको AI-संचालित अंतर्दृष्टि प्रदान की जा सके और सभी ऐप सुविधाएँ सक्षम की जा सकें।'; @override - String get tasksEmptyStateMessage => 'टास्क बनाने के लिए बातचीत शुरू करें।'; + String get tasksEmptyStateMessage => 'आपकी बातचीत से निकले काम यहाँ दिखेंगे।'; @override String get clearChatAction => 'चैट साफ़ करें'; @@ -13448,6 +13448,25 @@ class AppLocalizationsHi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ठीक करने के लिए टैप करें'; + @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 => 'इस काम को पूरा करने के लिए टिक करें'; + @override String get homePendingTitle => 'बातचीत आ रही हैं'; diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index 7f5f690ed6f..4bca5a74906 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -4928,7 +4928,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'; @@ -13501,6 +13501,25 @@ class AppLocalizationsHr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dodirnite za popravak'; + @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š'; + @override String get homePendingTitle => 'Razgovori stižu'; diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index 4e57f7bef9c..6fee156fbbb 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -4937,7 +4937,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'; @@ -13519,6 +13519,25 @@ class AppLocalizationsHu extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Koppints a javításhoz'; + @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'; + @override String get homePendingTitle => 'Beszélgetések úton'; diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 0425233673b..83982e7a44d 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -4917,7 +4917,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'; @@ -13477,6 +13477,25 @@ class AppLocalizationsId extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ketuk untuk memperbaiki'; + @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'; + @override String get homePendingTitle => 'Percakapan sedang diproses'; diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 900de08b19d..0e05418a852 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -4940,7 +4940,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'; @@ -13538,6 +13538,25 @@ class AppLocalizationsIt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tocca per risolvere'; + @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'; + @override String get homePendingTitle => 'Conversazioni in arrivo'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 905eb00458f..937d910b217 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -4831,7 +4831,7 @@ class AppLocalizationsJa extends AppLocalizations { '続行することで、会話、録音、個人情報は安全に当社のサーバーに保存されます。音声録音と文字起こしは、サードパーティのAIサービス(文字起こし用のDeepgramと分析用のOpenAIを含む)によって処理され、AI駆動のインサイトを提供し、すべてのアプリ機能を有効にします。'; @override - String get tasksEmptyStateMessage => '会話を始めてタスクを作成しましょう。'; + String get tasksEmptyStateMessage => '会話から見つかったToDoがここに表示されます。'; @override String get clearChatAction => 'チャットを消去'; @@ -13279,6 +13279,24 @@ class AppLocalizationsJa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'タップして修正'; + @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 => 'このタスクにチェックして完了する'; + @override String get homePendingTitle => '会話を準備中'; diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index 5b5f7a3b46d..dcc851011d6 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -4931,7 +4931,7 @@ class AppLocalizationsKn extends AppLocalizations { 'ಮುಂದುವರಿಯುವ ಮೂಲಕ, ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳು, ರೆಕಾರ್ಡಿಂಗ್‌ಗಳು ಮತ್ತು ವೈಯಕ್ತಿಕ ಮಾಹಿತಿಯನ್ನು ನಮ್ಮ ಸರ್ವರ್‌ಗಳಲ್ಲಿ ಸುರಕ್ಷಿತವಾಗಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ. ನಿಮ್ಮ ಆಡಿಯೋ ರೆಕಾರ್ಡಿಂಗ್‌ಗಳು ಮತ್ತು ಟ್ರಾನ್ಸ್‌ಕ್ರಿಪ್ಟ್‌ಗಳನ್ನು ಮೂರನೇ ವ್ಯಕ್ತಿಯ AI ಸೇವೆಗಳಿಂದ ಸಂಸ್ಕರಿಸಲಾಗುತ್ತದೆ (ಟ್ರಾನ್ಸ್‌ಕ್ರಿಪ್ಷನ್‌ಗಾಗಿ Deepgram ಮತ್ತು ವಿಶ್ಲೇಷಣೆಗಾಗಿ OpenAI ಸೇರಿದಂತೆ) AI-ಚಾಲಿತ ಒಳನೋಟಗಳನ್ನು ಒದಗಿಸಲು ಮತ್ತು ಎಲ್ಲಾ ಅಪ್ಲಿಕೇಶನ್ ವೈಶಿಷ್ಟ್ಯಗಳನ್ನು ಸಕ್ರಿಯಗೊಳಿಸಲು.'; @override - String get tasksEmptyStateMessage => 'ಕಾರ್ಯವನ್ನು ರಚಿಸಲು ಸಂಭಾಷಣೆಯನ್ನು ಪ್ರಾರಂಭಿಸಿ.'; + String get tasksEmptyStateMessage => 'ನಿಮ್ಮ ಸಂಭಾಷಣೆಗಳ ಕಾರ್ಯಗಳು ಇಲ್ಲಿ ಕಾಣಿಸುತ್ತವೆ.'; @override String get clearChatAction => 'ಚ್ಯಾಟ್ ತೀಕ್ಷ್ಣ ಮಾಡಿ'; @@ -13506,6 +13506,25 @@ class AppLocalizationsKn extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ಸರಿಪಡಿಸಲು ಟ್ಯಾಪ್ ಮಾಡಿ'; + @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 => 'ಮುಗಿಸಲು ಈ ಕಾರ್ಯವನ್ನು ಟಿಕ್ ಮಾಡಿ'; + @override String get homePendingTitle => 'ಸಂಭಾಷಣೆಗಳು ಬರುತ್ತಿವೆ'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 50609f36ee1..919859d987b 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -4832,7 +4832,7 @@ class AppLocalizationsKo extends AppLocalizations { '계속하면 대화, 녹음 및 개인 정보가 서버에 안전하게 저장됩니다. 오디오 녹음 및 텍스트 변환은 제3자 AI 서비스(전사를 위한 Deepgram 및 분석을 위한 OpenAI 포함)에 의해 처리되어 AI 기반 인사이트를 제공하고 모든 앱 기능을 활성화합니다.'; @override - String get tasksEmptyStateMessage => '대화를 시작하여 할 일을 만드세요.'; + String get tasksEmptyStateMessage => '대화에서 나온 할 일이 여기에 표시돼요.'; @override String get clearChatAction => '채팅 삭제'; @@ -13285,6 +13285,25 @@ class AppLocalizationsKo extends AppLocalizations { @override String get onboardingPermissionTapToFix => '탭하여 해결'; + @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 => '이 할 일을 체크해서 완료하기'; + @override String get homePendingTitle => '대화 준비 중'; diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 70bb8feff9e..c6c0299a50f 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -4911,7 +4911,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į'; @@ -13491,6 +13491,25 @@ class AppLocalizationsLt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Bakstelėkite, kad pataisytumėte'; + @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ą'; + @override String get homePendingTitle => 'Pokalbiai pakeliui'; diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 414f794f042..d2ecf651bd0 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -4918,7 +4918,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'; @@ -13496,6 +13496,25 @@ class AppLocalizationsLv extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Pieskarieties, lai labotu'; + @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'; + @override String get homePendingTitle => 'Sarunas ceļā'; diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index 492f60f30de..b9f41ba0c1c 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -4934,7 +4934,7 @@ class AppLocalizationsMk extends AppLocalizations { 'Со продолжување, вашите разговори, снимки и лични информации ќе бидат безбедно зачувани на нашите сервери. Вашите аудио снимки и транскрипти се обработуваат од AI услуги на трети страни (вклучувајќи Deepgram за транскрипција и OpenAI за анализа) за да ви обезбедат увиди базирани на AI и да ги овозможат сите функции на апликацијата.'; @override - String get tasksEmptyStateMessage => 'Започнете разговор за да создадете задача.'; + String get tasksEmptyStateMessage => 'Задачите од вашите разговори ќе се појават тука.'; @override String get clearChatAction => 'Очисти разговор'; @@ -13528,6 +13528,25 @@ class AppLocalizationsMk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Допрете за да поправите'; + @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 => 'Штиклирај ја оваа задача за да ја завршиш'; + @override String get homePendingTitle => 'Разговори на пат'; diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index ef4f5e1fc20..41af2732d40 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -4921,7 +4921,7 @@ class AppLocalizationsMr extends AppLocalizations { 'पुढे चालू ठेवल्यास, तुमच्या संभाषणा, रेकॉर्डिंग आणि वैयक्तिक माहिती आमच्या सर्व्हरवर सुरक्षितपणे साठवली जाईल. तुमच्या ऑडिओ रेकॉर्डिंग आणि ट्रान्सक्रिप्ट तृतीय-पक्ष AI सेवांद्वारे प्रक्रिया केली जातात (ट्रान्सक्रिप्शनसाठी Deepgram आणि विश्लेषणासाठी OpenAI सह) तुम्हाला AI-चालित अंतर्दृष्टी प्रदान करण्यासाठी आणि सर्व अॅप वैशिष्ट्ये सक्षम करण्यासाठी.'; @override - String get tasksEmptyStateMessage => 'कार्य तयार करण्यासाठी संभाषण सुरू करा.'; + String get tasksEmptyStateMessage => 'तुमच्या संभाषणांमधील कामे इथे दिसतील.'; @override String get clearChatAction => 'चॅट साफ करा'; @@ -13473,6 +13473,25 @@ class AppLocalizationsMr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'दुरुस्त करण्यासाठी टॅप करा'; + @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 => 'पूर्ण करण्यासाठी हे काम टिक करा'; + @override String get homePendingTitle => 'संभाषणे येत आहेत'; diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index f462bc215a4..602da3a65f7 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -4926,7 +4926,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'; @@ -13498,6 +13498,25 @@ class AppLocalizationsMs extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ketik untuk membetulkan'; + @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'; + @override String get homePendingTitle => 'Perbualan sedang diproses'; diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index c8398b3638a..499c8df7f42 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -4922,7 +4922,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'; @@ -13503,6 +13503,25 @@ class AppLocalizationsNl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tik om te herstellen'; + @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'; + @override String get homePendingTitle => 'Gesprekken onderweg'; diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index 3373bb5ba35..1933fbacf43 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -4912,7 +4912,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'; @@ -13469,6 +13469,25 @@ class AppLocalizationsNo extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Trykk for å fikse'; + @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'; + @override String get homePendingTitle => 'Samtaler på vei'; diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index 8940b590c47..46f5a0478d8 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -4920,7 +4920,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'; @@ -13506,6 +13506,25 @@ class AppLocalizationsPl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Dotknij, aby naprawić'; + @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ć'; + @override String get homePendingTitle => 'Rozmowy w drodze'; diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index 052203dd329..d75cd6785dd 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -4907,7 +4907,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'; @@ -13491,6 +13491,25 @@ class AppLocalizationsPt extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Toque para corrigir'; + @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'; + @override String get homePendingTitle => 'Conversas a caminho'; diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index 3fdf817d6f9..209cb6d3dce 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -4935,7 +4935,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'; @@ -13522,6 +13522,25 @@ class AppLocalizationsRo extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Atinge pentru a remedia'; + @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'; + @override String get homePendingTitle => 'Conversații pe drum'; diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index bc66c73897f..4e943b376b8 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -4923,7 +4923,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Продолжая, ваши разговоры, записи и личная информация будут надежно храниться на наших серверах. Ваши аудиозаписи и транскрипции обрабатываются сторонними AI-сервисами (включая Deepgram для транскрипции и OpenAI для анализа), чтобы предоставить вам аналитику на основе ИИ и обеспечить работу всех функций приложения.'; @override - String get tasksEmptyStateMessage => 'Начните разговор, чтобы создать задачу.'; + String get tasksEmptyStateMessage => 'Задачи из ваших разговоров появятся здесь.'; @override String get clearChatAction => 'Очистить чат'; @@ -13513,6 +13513,25 @@ class AppLocalizationsRu extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Нажмите, чтобы исправить'; + @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 => 'Отметьте эту задачу, чтобы завершить её'; + @override String get homePendingTitle => 'Разговоры в пути'; diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index 992f65ecbbb..fa27d714357 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -4907,7 +4907,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'; @@ -13463,6 +13463,25 @@ class AppLocalizationsSk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Ťuknutím opravíte'; + @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'; + @override String get homePendingTitle => 'Konverzácie na ceste'; diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index 5e70e058e6f..661fec9ca8e 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -4920,7 +4920,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'; @@ -13498,6 +13498,25 @@ class AppLocalizationsSl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tapnite za popravilo'; + @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š'; + @override String get homePendingTitle => 'Pogovori na poti'; diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index 48a3c0545f1..9859f13913c 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -4912,7 +4912,7 @@ class AppLocalizationsSr extends AppLocalizations { 'Настављањем, ваши разговори, снимци и лични подаци биће безбедно ускладиштени на нашим серверима. Ваши аудио снимци и транскрипти се обрађују од стране AI сервиса трећих страна (укључујући Deepgram за транскрипцију и OpenAI за анализу) како би вам пружили увиде засноване на вештачкој интелигенцији и омогућили све функције апликације.'; @override - String get tasksEmptyStateMessage => 'Започните разговор да бисте направили задатак.'; + String get tasksEmptyStateMessage => 'Задаци из ваших разговора појавиће се овде.'; @override String get clearChatAction => 'Обриши разговор'; @@ -13476,6 +13476,25 @@ class AppLocalizationsSr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Додирните да поправите'; + @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š'; + @override String get homePendingTitle => 'Разговори стижу'; diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 80f57b00c9b..21e6ea6a358 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -4913,7 +4913,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'; @@ -13472,6 +13472,25 @@ class AppLocalizationsSv extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Tryck för att åtgärda'; + @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'; + @override String get homePendingTitle => 'Samtal på väg'; diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index 2046de8856c..d8ef273fb26 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -4951,7 +4951,7 @@ class AppLocalizationsTa extends AppLocalizations { 'தொடர்வதன் மூலம், உங்கள் உரையாடல்கள், பதிவுகள் மற்றும் தனிப்பட்ட தகவல்கள் எங்கள் சேவையகங்களில் பாதுகாப்பாக சேமிக்கப்படும். உங்கள் ஆடியோ பதிவுகள் மற்றும் படியெடுப்புகள் மூன்றாம் தரப்பு AI சேவைகளால் செயலாக்கப்படுகின்றன (படியெடுப்பிற்கு Deepgram மற்றும் பகுப்பாய்விற்கு OpenAI உட்பட) AI இயக்கும் நுண்ணறிவுகளை உங்களுக்கு வழங்கவும் அனைத்து பயன்பாட்டு அம்சங்களையும் இயக்கவும்.'; @override - String get tasksEmptyStateMessage => 'ஒரு பணியை உருவாக்க உரையாடலைத் தொடங்குங்கள்.'; + String get tasksEmptyStateMessage => 'உங்கள் உரையாடல்களில் உள்ள பணிகள் இங்கே தோன்றும்.'; @override String get clearChatAction => 'உரையாடலைத் தெளிக்கவும்'; @@ -13559,6 +13559,25 @@ class AppLocalizationsTa extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'சரிசெய்ய தட்டவும்'; + @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 => 'முடிக்க இந்தப் பணியைக் குறியிடுங்கள்'; + @override String get homePendingTitle => 'உரையாடல்கள் வருகின்றன'; diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index f594175a2bd..17fc097a995 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -4939,7 +4939,7 @@ class AppLocalizationsTe extends AppLocalizations { 'కొనసాగించడం ద్వారా, మీ సంభాషణలు, రికార్డింగ్‌లు మరియు వ్యక్తిగత సమాచారం మా సర్వర్‌లలో సురక్షితంగా నిల్వ చేయబడతాయి. మీ ఆడియో రికార్డింగ్‌లు మరియు ట్రాన్‌స్క్రిప్ట్‌లు థర్డ్-పార్టీ AI సేవల ద్వారా ప్రాసెస్ చేయబడతాయి (ట్రాన్‌స్క్రిప్షన్ కోసం Deepgram మరియు విశ్లేషణ కోసం OpenAI సహా) AI-ఆధారిత అంతర్దృష్టులను అందించడానికి మరియు అన్ని యాప్ ఫీచర్‌లను ప్రారంభించడానికి.'; @override - String get tasksEmptyStateMessage => 'పనిని సృష్టించడానికి సంభాషణను ప్రారంభించండి.'; + String get tasksEmptyStateMessage => 'మీ సంభాషణల నుండి పనులు ఇక్కడ కనిపిస్తాయి.'; @override String get clearChatAction => 'చాట్‌ను క్లియర్ చేయండి'; @@ -13519,6 +13519,25 @@ class AppLocalizationsTe extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'సరిచేయడానికి నొక్కండి'; + @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 => 'పూర్తి చేయడానికి ఈ పనిని టిక్ చేయండి'; + @override String get homePendingTitle => 'సంభాషణలు వస్తున్నాయి'; diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 9c547e4e383..58d699c372c 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -4883,7 +4883,7 @@ class AppLocalizationsTh extends AppLocalizations { 'เมื่อดำเนินการต่อ การสนทนา การบันทึก และข้อมูลส่วนบุคคลของคุณจะถูกจัดเก็บอย่างปลอดภัยบนเซิร์ฟเวอร์ของเรา การบันทึกเสียงและการถอดความของคุณจะถูกประมวลผลโดยบริการ AI ของบุคคลที่สาม (รวมถึง Deepgram สำหรับการถอดความ และ OpenAI สำหรับการวิเคราะห์) เพื่อมอบข้อมูลเชิงลึกที่ขับเคลื่อนด้วย AI และเปิดใช้งานคุณสมบัติทั้งหมดของแอป'; @override - String get tasksEmptyStateMessage => 'เริ่มการสนทนาเพื่อสร้างงาน'; + String get tasksEmptyStateMessage => 'สิ่งที่ต้องทำจากบทสนทนาของคุณจะแสดงที่นี่'; @override String get clearChatAction => 'ล้างแชท'; @@ -13401,6 +13401,25 @@ class AppLocalizationsTh extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'แตะเพื่อแก้ไข'; + @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 => 'ติ๊กงานนี้เพื่อทำให้เสร็จ'; + @override String get homePendingTitle => 'บทสนทนากำลังมา'; diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 714481d069a..9b25fd28f29 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -4952,7 +4952,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'; @@ -13572,6 +13572,25 @@ class AppLocalizationsTl extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'I-tap para ayusin'; + @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'; + @override String get homePendingTitle => 'Paparating na ang mga usapan'; diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 2622db97168..eba621b5e10 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -4919,7 +4919,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'; @@ -13480,6 +13480,25 @@ class AppLocalizationsTr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Düzeltmek için dokunun'; + @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'; + @override String get homePendingTitle => 'Konuşmalar yolda'; diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index d844460e381..dfe63f13aee 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -4917,7 +4917,7 @@ class AppLocalizationsUk extends AppLocalizations { 'Продовжуючи, ваші розмови, записи та особиста інформація будуть надійно зберігатися на наших серверах. Ваші аудіозаписи та транскрипції обробляються сторонніми AI-сервісами (включаючи Deepgram для транскрипції та OpenAI для аналізу), щоб надати вам аналітику на основі ШІ та увімкнути всі функції додатку.'; @override - String get tasksEmptyStateMessage => 'Почніть розмову, щоб створити завдання.'; + String get tasksEmptyStateMessage => 'Завдання з ваших розмов з\'являться тут.'; @override String get clearChatAction => 'Очистити чат'; @@ -13497,6 +13497,25 @@ class AppLocalizationsUk extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Торкніться, щоб виправити'; + @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 => 'Позначте це завдання, щоб завершити його'; + @override String get homePendingTitle => 'Розмови в дорозі'; diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index ffd92259b85..58f3eda1694 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -4919,7 +4919,7 @@ class AppLocalizationsUr extends AppLocalizations { 'جاری رکھ کر، آپ کی بات چیت، ریکارڈنگز اور ذاتی معلومات ہمارے سرورز پر محفوظ طریقے سے ذخیرہ کی جائیں گی۔ آپ کی آڈیو ریکارڈنگز اور ٹرانسکرپٹس تھرڈ پارٹی AI سروسز کے ذریعے پراسیس کی جاتی ہیں (بشمول ٹرانسکرپشن کے لیے Deepgram اور تجزیے کے لیے OpenAI) تاکہ آپ کو AI سے چلنے والی بصیرتیں فراہم کی جا سکیں اور ایپ کی تمام خصوصیات کو فعال کیا جا سکے۔'; @override - String get tasksEmptyStateMessage => 'ٹاسک بنانے کے لیے گفتگو شروع کریں۔'; + String get tasksEmptyStateMessage => 'آپ کی گفتگو سے کام یہاں ظاہر ہوں گے۔'; @override String get clearChatAction => 'بات کو صاف کریں'; @@ -13481,6 +13481,25 @@ class AppLocalizationsUr extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'ٹھیک کرنے کے لیے ٹیپ کریں'; + @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 => 'مکمل کرنے کے لیے اس کام پر نشان لگائیں'; + @override String get homePendingTitle => 'گفتگوئیں آ رہی ہیں'; diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index d47342aebb4..8bc8af1ba64 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -4921,7 +4921,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'; @@ -13465,6 +13465,25 @@ class AppLocalizationsVi extends AppLocalizations { @override String get onboardingPermissionTapToFix => 'Nhấn để khắc phục'; + @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'; + @override String get homePendingTitle => 'Cuộc trò chuyện đang đến'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 5cafbcfb03a..db833da6859 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -4827,7 +4827,7 @@ class AppLocalizationsZh extends AppLocalizations { '继续即表示您的对话、录音和个人信息将安全存储在我们的服务器上。您的音频录音和转录由第三方AI服务处理(包括用于转录的Deepgram和用于分析的OpenAI),以为您提供AI驱动的洞察并启用所有应用功能。'; @override - String get tasksEmptyStateMessage => '开始对话以创建任务。'; + String get tasksEmptyStateMessage => '对话中的待办事项会显示在这里。'; @override String get clearChatAction => '清除聊天'; @@ -13260,6 +13260,24 @@ class AppLocalizationsZh extends AppLocalizations { @override String get onboardingPermissionTapToFix => '点按以修复'; + @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 => '勾选这项任务来完成它'; + @override String get homePendingTitle => '对话正在生成'; diff --git a/app/lib/l10n/app_lt.arb b/app/lib/l10n/app_lt.arb index 959c9b2df40..86c894a0d72 100644 --- a/app/lib/l10n/app_lt.arb +++ b/app/lib/l10n/app_lt.arb @@ -1937,7 +1937,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Dar neužsisakiau", "onboardingOmiOrdered": "Jau užsisakiau", "onboardingPermissionTapToFix": "Bakstelėkite, kad pataisytumėte", + "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ą", "homePendingTitle": "Pokalbiai pakeliui", "homePendingCatchingUp": "Apdorojamas ankstesnis garsas", "onTheWayTitle": "Pakeliui", diff --git a/app/lib/l10n/app_lv.arb b/app/lib/l10n/app_lv.arb index f77735fcebb..a327ee86397 100644 --- a/app/lib/l10n/app_lv.arb +++ b/app/lib/l10n/app_lv.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Vēl neesmu pasūtījis", "onboardingOmiOrdered": "Esmu to pasūtījis", "onboardingPermissionTapToFix": "Pieskarieties, lai labotu", + "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", "homePendingTitle": "Sarunas ceļā", "homePendingCatchingUp": "Apstrādājam iepriekšējo audio", "onTheWayTitle": "Ceļā", diff --git a/app/lib/l10n/app_mk.arb b/app/lib/l10n/app_mk.arb index 540f37192d4..dbecdbc2c97 100644 --- a/app/lib/l10n/app_mk.arb +++ b/app/lib/l10n/app_mk.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Сè уште не сум нарачал", "onboardingOmiOrdered": "Го нарачав", "onboardingPermissionTapToFix": "Допрете за да поправите", + "voiceProfileCardDismissed": "Гласовниот профил можете да го поставите во секое време во Поставки.", + "homeNewRecordingButton": "Ново снимање", + "chatWelcomeMessage": "Здраво, јас сум Omi. Прашај ме било што за она за што си зборувал: што рекол некој, што си ветил или резиме на твојот ден. Можам и да додавам задачи и да паметам работи за тебе.", + "onboardingStarterTaskAsk": "Прашај го Omi за твојот ден", + "onboardingStarterTaskRecord": "Сними разговор со Omi", + "onboardingStarterTaskCheck": "Штиклирај ја оваа задача за да ја завршиш", "homePendingTitle": "Разговори на пат", "homePendingCatchingUp": "Ги обработуваме претходните аудиозаписи", "onTheWayTitle": "На пат", diff --git a/app/lib/l10n/app_mr.arb b/app/lib/l10n/app_mr.arb index 4f72b05b0e5..273d19b54ad 100644 --- a/app/lib/l10n/app_mr.arb +++ b/app/lib/l10n/app_mr.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "अजून ऑर्डर केलेला नाही", "onboardingOmiOrdered": "मी ऑर्डर केला आहे", "onboardingPermissionTapToFix": "दुरुस्त करण्यासाठी टॅप करा", + "voiceProfileCardDismissed": "सेटिंग्जमध्ये कधीही व्हॉइस प्रोफाइल सेट करू शकता.", + "homeNewRecordingButton": "नवीन रेकॉर्डिंग", + "chatWelcomeMessage": "नमस्कार, मी Omi आहे. तुम्ही ज्याबद्दल बोललात त्याबद्दल काहीही विचारा: कोणी काय सांगितले, तुम्ही काय वचन दिले किंवा तुमच्या दिवसाचा सारांश. मी कामे जोडू शकतो आणि तुमच्यासाठी गोष्टी लक्षातही ठेवू शकतो.", + "onboardingStarterTaskAsk": "Omi ला तुमच्या दिवसाबद्दल विचारा", + "onboardingStarterTaskRecord": "Omi ने एक संभाषण रेकॉर्ड करा", + "onboardingStarterTaskCheck": "पूर्ण करण्यासाठी हे काम टिक करा", "homePendingTitle": "संभाषणे येत आहेत", "homePendingCatchingUp": "आधीचा ऑडिओ प्रक्रिया होत आहे", "onTheWayTitle": "मार्गावर", diff --git a/app/lib/l10n/app_ms.arb b/app/lib/l10n/app_ms.arb index 82ba55631d3..94152ec39f2 100644 --- a/app/lib/l10n/app_ms.arb +++ b/app/lib/l10n/app_ms.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Belum memesan", "onboardingOmiOrdered": "Saya sudah memesannya", "onboardingPermissionTapToFix": "Ketik untuk membetulkan", + "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", "homePendingTitle": "Perbualan sedang diproses", "homePendingCatchingUp": "Mengejar audio terdahulu", "onTheWayTitle": "Dalam perjalanan", diff --git a/app/lib/l10n/app_nl.arb b/app/lib/l10n/app_nl.arb index eb3bc69f887..2a32b4711c7 100644 --- a/app/lib/l10n/app_nl.arb +++ b/app/lib/l10n/app_nl.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Nog niet besteld", "onboardingOmiOrdered": "Ik heb hem besteld", "onboardingPermissionTapToFix": "Tik om te herstellen", + "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", "homePendingTitle": "Gesprekken onderweg", "homePendingCatchingUp": "Eerdere audio wordt ingehaald", "onTheWayTitle": "Onderweg", diff --git a/app/lib/l10n/app_no.arb b/app/lib/l10n/app_no.arb index 79092033417..c963193d6c1 100644 --- a/app/lib/l10n/app_no.arb +++ b/app/lib/l10n/app_no.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Ikke bestilt ennå", "onboardingOmiOrdered": "Jeg har bestilt den", "onboardingPermissionTapToFix": "Trykk for å fikse", + "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", "homePendingTitle": "Samtaler på vei", "homePendingCatchingUp": "Tar igjen tidligere lyd", "onTheWayTitle": "På vei", diff --git a/app/lib/l10n/app_pl.arb b/app/lib/l10n/app_pl.arb index 1d1ca5559bc..7748b6fc2a1 100644 --- a/app/lib/l10n/app_pl.arb +++ b/app/lib/l10n/app_pl.arb @@ -1505,7 +1505,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", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Jeszcze nie zamówiłem/am", "onboardingOmiOrdered": "Już zamówiłem/am", "onboardingPermissionTapToFix": "Dotknij, aby naprawić", + "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ć", "homePendingTitle": "Rozmowy w drodze", "homePendingCatchingUp": "Nadrabiamy wcześniejsze nagrania", "onTheWayTitle": "W drodze", diff --git a/app/lib/l10n/app_pt.arb b/app/lib/l10n/app_pt.arb index fbd1b7ff8f6..6d4167214f8 100644 --- a/app/lib/l10n/app_pt.arb +++ b/app/lib/l10n/app_pt.arb @@ -1505,7 +1505,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", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Ainda não encomendei", "onboardingOmiOrdered": "Já encomendei", "onboardingPermissionTapToFix": "Toque para corrigir", + "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", "homePendingTitle": "Conversas a caminho", "homePendingCatchingUp": "Processando áudio anterior", "onTheWayTitle": "A caminho", diff --git a/app/lib/l10n/app_ro.arb b/app/lib/l10n/app_ro.arb index 771565ddf67..889dd7daf51 100644 --- a/app/lib/l10n/app_ro.arb +++ b/app/lib/l10n/app_ro.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Încă nu am comandat", "onboardingOmiOrdered": "L-am comandat", "onboardingPermissionTapToFix": "Atinge pentru a remedia", + "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", "homePendingTitle": "Conversații pe drum", "homePendingCatchingUp": "Recuperăm audio anterior", "onTheWayTitle": "Pe drum", diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 3c08010f62a..65765dd25b3 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "Длительность: {seconds} секунд", "additionalSpeechSampleRemoved": "Дополнительный образец голоса удален", "consentDataMessage": "Продолжая, ваши разговоры, записи и личная информация будут надежно храниться на наших серверах. Ваши аудиозаписи и транскрипции обрабатываются сторонними AI-сервисами (включая Deepgram для транскрипции и OpenAI для анализа), чтобы предоставить вам аналитику на основе ИИ и обеспечить работу всех функций приложения.", - "tasksEmptyStateMessage": "Начните разговор, чтобы создать задачу.", + "tasksEmptyStateMessage": "Задачи из ваших разговоров появятся здесь.", "clearChatAction": "Очистить чат", "enableApps": "Включить приложения", "omiAppName": "Omi", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Ещё не заказал", "onboardingOmiOrdered": "Я уже заказал", "onboardingPermissionTapToFix": "Нажмите, чтобы исправить", + "voiceProfileCardDismissed": "Голосовой профиль можно настроить в любое время в Настройках.", + "homeNewRecordingButton": "Новая запись", + "chatWelcomeMessage": "Привет, я Omi. Спрашивайте обо всём, о чём вы говорили: что сказал кто-то, что вы пообещали или как прошёл ваш день. Я также могу добавлять задачи и запоминать важное за вас.", + "onboardingStarterTaskAsk": "Спросите Omi о своём дне", + "onboardingStarterTaskRecord": "Запишите разговор с Omi", + "onboardingStarterTaskCheck": "Отметьте эту задачу, чтобы завершить её", "homePendingTitle": "Разговоры в пути", "homePendingCatchingUp": "Обрабатываем более раннее аудио", "onTheWayTitle": "В пути", diff --git a/app/lib/l10n/app_sk.arb b/app/lib/l10n/app_sk.arb index a1f07f83d52..51c07b5ab13 100644 --- a/app/lib/l10n/app_sk.arb +++ b/app/lib/l10n/app_sk.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Ešte neobjednané", "onboardingOmiOrdered": "Už som ho objednal/a", "onboardingPermissionTapToFix": "Ťuknutím opravíte", + "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", "homePendingTitle": "Konverzácie na ceste", "homePendingCatchingUp": "Spracúvame skorší zvuk", "onTheWayTitle": "Na ceste", diff --git a/app/lib/l10n/app_sl.arb b/app/lib/l10n/app_sl.arb index 56e44d8c9ce..f6abe7d5ccf 100644 --- a/app/lib/l10n/app_sl.arb +++ b/app/lib/l10n/app_sl.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Še nisem naročil/a", "onboardingOmiOrdered": "Naročil/a sem ga", "onboardingPermissionTapToFix": "Tapnite za popravilo", + "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š", "homePendingTitle": "Pogovori na poti", "homePendingCatchingUp": "Obdelujemo prejšnji zvok", "onTheWayTitle": "Na poti", diff --git a/app/lib/l10n/app_sr.arb b/app/lib/l10n/app_sr.arb index 6a2189072ff..7faa6b8cffb 100644 --- a/app/lib/l10n/app_sr.arb +++ b/app/lib/l10n/app_sr.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Још нисам наручио/ла", "onboardingOmiOrdered": "Наручио/ла сам га", "onboardingPermissionTapToFix": "Додирните да поправите", + "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š", "homePendingTitle": "Разговори стижу", "homePendingCatchingUp": "Обрађујемо ранији звук", "onTheWayTitle": "На путу", diff --git a/app/lib/l10n/app_sv.arb b/app/lib/l10n/app_sv.arb index d18c253a27b..8fafb81fa89 100644 --- a/app/lib/l10n/app_sv.arb +++ b/app/lib/l10n/app_sv.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Inte beställt än", "onboardingOmiOrdered": "Jag har beställt den", "onboardingPermissionTapToFix": "Tryck för att åtgärda", + "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", "homePendingTitle": "Samtal på väg", "homePendingCatchingUp": "Kommer ikapp tidigare ljud", "onTheWayTitle": "På väg", diff --git a/app/lib/l10n/app_ta.arb b/app/lib/l10n/app_ta.arb index 48064b97963..cba2c111ee9 100644 --- a/app/lib/l10n/app_ta.arb +++ b/app/lib/l10n/app_ta.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "இன்னும் ஆர்டர் செய்யவில்லை", "onboardingOmiOrdered": "நான் ஆர்டர் செய்துவிட்டேன்", "onboardingPermissionTapToFix": "சரிசெய்ய தட்டவும்", + "voiceProfileCardDismissed": "அமைப்புகளில் எப்போது வேண்டுமானாலும் குரல் சுயவிவரத்தை அமைக்கலாம்.", + "homeNewRecordingButton": "புதிய பதிவு", + "chatWelcomeMessage": "வணக்கம், நான் Omi. நீங்கள் பேசிய எதைப் பற்றியும் கேளுங்கள்: யார் என்ன சொன்னார்கள், நீங்கள் என்ன வாக்குறுதி அளித்தீர்கள் அல்லது உங்கள் நாளின் சுருக்கம். பணிகளைச் சேர்க்கவும் உங்களுக்காக விஷயங்களை நினைவில் வைக்கவும் என்னால் முடியும்.", + "onboardingStarterTaskAsk": "உங்கள் நாளைப் பற்றி Omi-யிடம் கேளுங்கள்", + "onboardingStarterTaskRecord": "Omi மூலம் ஒரு உரையாடலைப் பதிவுசெய்யுங்கள்", + "onboardingStarterTaskCheck": "முடிக்க இந்தப் பணியைக் குறியிடுங்கள்", "homePendingTitle": "உரையாடல்கள் வருகின்றன", "homePendingCatchingUp": "முந்தைய ஆடியோ செயலாக்கப்படுகிறது", "onTheWayTitle": "வழியில்", diff --git a/app/lib/l10n/app_te.arb b/app/lib/l10n/app_te.arb index 7fbc563176e..20c12500166 100644 --- a/app/lib/l10n/app_te.arb +++ b/app/lib/l10n/app_te.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "ఇంకా ఆర్డర్ చేయలేదు", "onboardingOmiOrdered": "నేను ఆర్డర్ చేశాను", "onboardingPermissionTapToFix": "సరిచేయడానికి నొక్కండి", + "voiceProfileCardDismissed": "సెట్టింగ్‌లలో ఎప్పుడైనా వాయిస్ ప్రొఫైల్ సెటప్ చేయవచ్చు.", + "homeNewRecordingButton": "కొత్త రికార్డింగ్", + "chatWelcomeMessage": "హాయ్, నేను Omi. మీరు మాట్లాడిన దేని గురించైనా అడగండి: ఎవరు ఏమన్నారు, మీరు ఏమి మాట ఇచ్చారు లేదా మీ రోజు సారాంశం. నేను పనులను జోడించగలను, మీ కోసం విషయాలు గుర్తుంచుకోగలను.", + "onboardingStarterTaskAsk": "మీ రోజు గురించి Omi ని అడగండి", + "onboardingStarterTaskRecord": "Omi తో ఒక సంభాషణను రికార్డ్ చేయండి", + "onboardingStarterTaskCheck": "పూర్తి చేయడానికి ఈ పనిని టిక్ చేయండి", "homePendingTitle": "సంభాషణలు వస్తున్నాయి", "homePendingCatchingUp": "మునుపటి ఆడియో ప్రాసెస్ అవుతోంది", "onTheWayTitle": "మార్గంలో ఉంది", diff --git a/app/lib/l10n/app_th.arb b/app/lib/l10n/app_th.arb index 5aec684ba42..2d22e989383 100644 --- a/app/lib/l10n/app_th.arb +++ b/app/lib/l10n/app_th.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "ระยะเวลา: {seconds} วินาที", "additionalSpeechSampleRemoved": "ลบตัวอย่างเสียงเพิ่มเติมแล้ว", "consentDataMessage": "เมื่อดำเนินการต่อ การสนทนา การบันทึก และข้อมูลส่วนบุคคลของคุณจะถูกจัดเก็บอย่างปลอดภัยบนเซิร์ฟเวอร์ของเรา การบันทึกเสียงและการถอดความของคุณจะถูกประมวลผลโดยบริการ AI ของบุคคลที่สาม (รวมถึง Deepgram สำหรับการถอดความ และ OpenAI สำหรับการวิเคราะห์) เพื่อมอบข้อมูลเชิงลึกที่ขับเคลื่อนด้วย AI และเปิดใช้งานคุณสมบัติทั้งหมดของแอป", - "tasksEmptyStateMessage": "เริ่มการสนทนาเพื่อสร้างงาน", + "tasksEmptyStateMessage": "สิ่งที่ต้องทำจากบทสนทนาของคุณจะแสดงที่นี่", "clearChatAction": "ล้างแชท", "enableApps": "เปิดใช้งานแอป", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "ยังไม่ได้สั่งซื้อ", "onboardingOmiOrdered": "สั่งซื้อแล้ว", "onboardingPermissionTapToFix": "แตะเพื่อแก้ไข", + "voiceProfileCardDismissed": "คุณตั้งค่าโปรไฟล์เสียงได้ทุกเมื่อในการตั้งค่า", + "homeNewRecordingButton": "บันทึกใหม่", + "chatWelcomeMessage": "สวัสดี ฉันคือ Omi ถามอะไรก็ได้เกี่ยวกับสิ่งที่คุณคุยไว้ เช่น ใครพูดอะไร คุณสัญญาอะไรไว้ หรือสรุปวันของคุณ ฉันยังเพิ่มสิ่งที่ต้องทำและจำเรื่องต่าง ๆ ให้คุณได้ด้วย", + "onboardingStarterTaskAsk": "ถาม Omi เกี่ยวกับวันของคุณ", + "onboardingStarterTaskRecord": "บันทึกบทสนทนาด้วย Omi", + "onboardingStarterTaskCheck": "ติ๊กงานนี้เพื่อทำให้เสร็จ", "homePendingTitle": "บทสนทนากำลังมา", "homePendingCatchingUp": "กำลังประมวลผลเสียงก่อนหน้า", "onTheWayTitle": "กำลังมา", diff --git a/app/lib/l10n/app_tl.arb b/app/lib/l10n/app_tl.arb index 4fc10a65d11..40ed6ce7bca 100644 --- a/app/lib/l10n/app_tl.arb +++ b/app/lib/l10n/app_tl.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "Hindi pa naka-order", "onboardingOmiOrdered": "Na-order ko na", "onboardingPermissionTapToFix": "I-tap para ayusin", + "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", "homePendingTitle": "Paparating na ang mga usapan", "homePendingCatchingUp": "Hinahabol ang naunang audio", "onTheWayTitle": "Paparating", diff --git a/app/lib/l10n/app_tr.arb b/app/lib/l10n/app_tr.arb index c50dbbc8174..b45990dd23f 100644 --- a/app/lib/l10n/app_tr.arb +++ b/app/lib/l10n/app_tr.arb @@ -1505,7 +1505,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", @@ -4164,6 +4164,12 @@ "onboardingOmiNotOrdered": "Henüz sipariş vermedim", "onboardingOmiOrdered": "Sipariş verdim", "onboardingPermissionTapToFix": "Düzeltmek için dokunun", + "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", "homePendingTitle": "Konuşmalar yolda", "homePendingCatchingUp": "Önceki ses işleniyor", "onTheWayTitle": "Yolda", diff --git a/app/lib/l10n/app_uk.arb b/app/lib/l10n/app_uk.arb index d3ba097e59e..130035106cb 100644 --- a/app/lib/l10n/app_uk.arb +++ b/app/lib/l10n/app_uk.arb @@ -1505,7 +1505,7 @@ "durationSeconds": "Тривалість: {seconds} секунд", "additionalSpeechSampleRemoved": "Додатковий зразок голосу видалено", "consentDataMessage": "Продовжуючи, ваші розмови, записи та особиста інформація будуть надійно зберігатися на наших серверах. Ваші аудіозаписи та транскрипції обробляються сторонніми AI-сервісами (включаючи Deepgram для транскрипції та OpenAI для аналізу), щоб надати вам аналітику на основі ШІ та увімкнути всі функції додатку.", - "tasksEmptyStateMessage": "Почніть розмову, щоб створити завдання.", + "tasksEmptyStateMessage": "Завдання з ваших розмов з'являться тут.", "clearChatAction": "Очистити чат", "enableApps": "Увімкнути додатки", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Ще не замовив", "onboardingOmiOrdered": "Я вже замовив", "onboardingPermissionTapToFix": "Торкніться, щоб виправити", + "voiceProfileCardDismissed": "Голосовий профіль можна налаштувати будь-коли в Налаштуваннях.", + "homeNewRecordingButton": "Новий запис", + "chatWelcomeMessage": "Привіт, я Omi. Питайте про все, про що ви говорили: що хтось сказав, що ви пообіцяли або як минув ваш день. Я також можу додавати завдання й запам'ятовувати важливе за вас.", + "onboardingStarterTaskAsk": "Запитайте Omi про свій день", + "onboardingStarterTaskRecord": "Запишіть розмову з Omi", + "onboardingStarterTaskCheck": "Позначте це завдання, щоб завершити його", "homePendingTitle": "Розмови в дорозі", "homePendingCatchingUp": "Обробляємо попереднє аудіо", "onTheWayTitle": "У дорозі", diff --git a/app/lib/l10n/app_ur.arb b/app/lib/l10n/app_ur.arb index a6caee021ab..bb1528706eb 100644 --- a/app/lib/l10n/app_ur.arb +++ b/app/lib/l10n/app_ur.arb @@ -5786,7 +5786,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" }, @@ -11552,6 +11552,12 @@ "onboardingOmiNotOrdered": "ابھی آرڈر نہیں کیا", "onboardingOmiOrdered": "میں نے آرڈر کر دیا ہے", "onboardingPermissionTapToFix": "ٹھیک کرنے کے لیے ٹیپ کریں", + "voiceProfileCardDismissed": "آپ کسی بھی وقت سیٹنگز میں وائس پروفائل سیٹ کر سکتے ہیں۔", + "homeNewRecordingButton": "نئی ریکارڈنگ", + "chatWelcomeMessage": "ہیلو، میں Omi ہوں۔ آپ نے جس بارے میں بات کی، اس کے بارے میں کچھ بھی پوچھیں: کسی نے کیا کہا، آپ نے کیا وعدہ کیا یا آپ کے دن کا خلاصہ۔ میں کام بھی شامل کر سکتا ہوں اور آپ کے لیے باتیں یاد بھی رکھ سکتا ہوں۔", + "onboardingStarterTaskAsk": "Omi سے اپنے دن کے بارے میں پوچھیں", + "onboardingStarterTaskRecord": "Omi کے ساتھ ایک گفتگو ریکارڈ کریں", + "onboardingStarterTaskCheck": "مکمل کرنے کے لیے اس کام پر نشان لگائیں", "homePendingTitle": "گفتگوئیں آ رہی ہیں", "homePendingCatchingUp": "پچھلی آڈیو پر کارروائی ہو رہی ہے", "onTheWayTitle": "راستے میں", diff --git a/app/lib/l10n/app_vi.arb b/app/lib/l10n/app_vi.arb index 71aca4a641f..f40b5ad1824 100644 --- a/app/lib/l10n/app_vi.arb +++ b/app/lib/l10n/app_vi.arb @@ -1505,7 +1505,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", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "Chưa đặt hàng", "onboardingOmiOrdered": "Tôi đã đặt hàng", "onboardingPermissionTapToFix": "Nhấn để khắc phục", + "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", "homePendingTitle": "Cuộc trò chuyện đang đến", "homePendingCatchingUp": "Đang xử lý âm thanh trước đó", "onTheWayTitle": "Đang đến", diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index d66c00eb053..432bb4f5809 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -1508,7 +1508,7 @@ "durationSeconds": "时长: {seconds} 秒", "additionalSpeechSampleRemoved": "已删除附加语音样本", "consentDataMessage": "继续即表示您的对话、录音和个人信息将安全存储在我们的服务器上。您的音频录音和转录由第三方AI服务处理(包括用于转录的Deepgram和用于分析的OpenAI),以为您提供AI驱动的洞察并启用所有应用功能。", - "tasksEmptyStateMessage": "开始对话以创建任务。", + "tasksEmptyStateMessage": "对话中的待办事项会显示在这里。", "clearChatAction": "清除聊天", "enableApps": "启用应用", "omiAppName": "Omi", @@ -4129,6 +4129,12 @@ "onboardingOmiNotOrdered": "还没下单", "onboardingOmiOrdered": "我已下单", "onboardingPermissionTapToFix": "点按以修复", + "voiceProfileCardDismissed": "你可以随时在设置中设置声音档案。", + "homeNewRecordingButton": "新录音", + "chatWelcomeMessage": "你好,我是 Omi。关于你聊过的任何事都可以问我:某人说了什么、你答应过什么,或者今天的总结。我还能帮你添加待办、记住重要的事。", + "onboardingStarterTaskAsk": "问问 Omi 你今天过得怎么样", + "onboardingStarterTaskRecord": "用 Omi 录一段对话", + "onboardingStarterTaskCheck": "勾选这项任务来完成它", "homePendingTitle": "对话正在生成", "homePendingCatchingUp": "正在处理之前的音频", "onTheWayTitle": "处理中", diff --git a/app/lib/pages/action_items/action_items_page.dart b/app/lib/pages/action_items/action_items_page.dart index 39b0119cd49..d4bfb532910 100644 --- a/app/lib/pages/action_items/action_items_page.dart +++ b/app/lib/pages/action_items/action_items_page.dart @@ -1,8 +1,8 @@ +import 'package:omi/services/experiments/home_redesign.dart'; 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 +59,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 +204,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 +224,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 +407,7 @@ class _ActionItemsPageState extends State with AutomaticKeepAli excludeFromSemantics: true, onTap: () {}, child: RefreshIndicator( + notificationPredicate: OmiRefreshControl.materialPredicate, onRefresh: () async { OmiHaptics.medium(); return provider.forceRefreshActionItems(); @@ -478,6 +419,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 +443,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 +462,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 +475,8 @@ class _ActionItemsPageState extends State with AutomaticKeepAli child: KeyedSubtree( key: const ValueKey('omi.action_items.empty'), child: OmiEmptyState( - icon: Icons.task_alt_rounded, + // home-redesign: text only; today's Home keeps the icon. + icon: HomeRedesign.on ? null : Icons.task_alt_rounded, title: context.l10n.noTasksYet, titleLayoutReference: context.l10n.noConversationsYet, message: context.l10n.tasksEmptyStateMessage, @@ -547,6 +496,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 +518,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 +997,86 @@ 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; + // home-redesign: the row shows its new state and folds away; today's Home just moves it. + final animate = HomeRedesign.on && 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 +1138,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 +1185,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 +1210,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..f47a6064cd5 100644 --- a/app/lib/pages/capture/widgets/widgets.dart +++ b/app/lib/pages/capture/widgets/widgets.dart @@ -1,3 +1,4 @@ +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:omi/utils/platform/platform_manager.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -21,11 +22,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 && HomeRedesign.on) return const SizedBox(); return Consumer( builder: (context, provider, child) { if (provider.isLoading) return const SizedBox(); @@ -43,6 +62,8 @@ class SpeechProfileCardWidget extends StatelessWidget { label: context.l10n.teachOmiYourVoice, // A dot marks a setup step still to do. badge: true, + // home-redesign: closable; today's Home keeps it until a profile exists. + onDismiss: HomeRedesign.on ? _dismiss : null, onTap: () async { PlatformManager.instance.analytics.pageOpened('Speech Profile Memories'); bool hasSpeakerProfile = SharedPreferencesUtil().hasSpeakerProfile; @@ -65,44 +86,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..5cba3e05159 100644 --- a/app/lib/pages/chat/widgets/chat_starters.dart +++ b/app/lib/pages/chat/widgets/chat_starters.dart @@ -1,8 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:omi/services/experiments/home_redesign.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 +20,47 @@ 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. + /// Only in the redesigned Home (home-redesign experiment); today's chat opens as before. + static bool get pending => + HomeRedesign.on && 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 +97,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 +199,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 3471c3d6d93..1b3b2b084a8 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -30,6 +30,7 @@ import 'package:omi/pages/conversations/widgets/date_list_item.dart'; import 'package:omi/pages/conversations/widgets/empty_conversations.dart'; import 'package:omi/pages/conversations/sync_pending_work.dart'; import 'package:omi/pages/conversations/widgets/home_pending_row.dart'; +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:omi/pages/conversations/widgets/recording_list_item.dart'; import 'package:omi/pages/home/widgets/home_daily_recaps.dart'; import 'package:omi/ui/ui.dart'; @@ -321,9 +322,14 @@ class _ConversationsPageState extends State with AutomaticKee if (mounted) setState(() {}); } + void _onHomeRedesign() { + if (mounted) setState(() {}); + } + @override void initState() { super.initState(); + HomeRedesign.listenable.addListener(_onHomeRedesign); _scheduleDayRollover(); _pendingWork.addListener(_onPendingWorkChanged); unawaited(_pendingWork.refresh()); @@ -427,6 +433,7 @@ class _ConversationsPageState extends State with AutomaticKee @override void dispose() { + HomeRedesign.listenable.removeListener(_onHomeRedesign); _pendingWork.removeListener(_onPendingWorkChanged); _dayTimer?.cancel(); for (final timer in _deferred) { @@ -522,6 +529,8 @@ class _ConversationsPageState extends State with AutomaticKee // Placing the pending row reads the clock and reshapes the top of the list: redo it whenever the // row appears or goes away, not only when the conversation list changes. final syncPendingVisible = context.select(SyncPendingWork.shouldShow); + // home-redesign experiment: the redesigned Home, or today's (control). + final redesign = HomeRedesign.on; return Selector2( selector: (_, conversationProvider, recordingsProvider) => _conversationPageSnapshot(conversationProvider, recordingsProvider), @@ -581,21 +590,26 @@ class _ConversationsPageState extends State with AutomaticKee // No list yet (loading, empty, a status): the row opens Today itself, where the list's Today lands. final pendingOpensToday = showPending && listRows.isEmpty; + Future refresh() async { + _lastLoadMoreRequestKey = null; + unawaited(_pendingWork.refresh()); + Provider.of(context, listen: false).refreshInProgressConversations(); + await Future.wait([ + convoProvider.getInitialConversations(), + Provider.of(context, listen: false).refresh(), + if (_recapsKey.currentState != null) _recapsKey.currentState!.refresh(), + // Review's entry hides when caught up; a pull is how a hidden one comes back. + if (Provider.of(context, listen: false) case final review? + when review.availability != ReviewAvailability.off) + review.load(), + ]); + } + return RefreshIndicator( + notificationPredicate: redesign ? OmiRefreshControl.materialPredicate : defaultScrollNotificationPredicate, onRefresh: () async { HapticFeedback.mediumImpact(); - _lastLoadMoreRequestKey = null; - unawaited(_pendingWork.refresh()); - Provider.of(context, listen: false).refreshInProgressConversations(); - await Future.wait([ - convoProvider.getInitialConversations(), - Provider.of(context, listen: false).refresh(), - if (_recapsKey.currentState != null) _recapsKey.currentState!.refresh(), - // Review's entry hides when caught up; a pull is how a hidden one comes back. - if (Provider.of(context, listen: false) case final review? - when review.availability != ReviewAvailability.off) - review.load(), - ]); + await refresh(); }, color: OmiColors.onAccent, backgroundColor: OmiColors.accent, @@ -603,8 +617,9 @@ class _ConversationsPageState extends State with AutomaticKee controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ + if (redesign && OmiRefreshControl.supported) OmiRefreshControl(onRefresh: refresh), // The live capture row: recording, a call, or a conversation being captured. - const SliverToBoxAdapter(child: ConversationCaptureWidget(showsCall: true)), + SliverToBoxAdapter(child: ConversationCaptureWidget(showsCall: true, deviceStatusInBottomBar: redesign)), 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..e1ae8cfa10e --- /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..f10e928031b 100644 --- a/app/lib/pages/conversations/widgets/conversation_list_item.dart +++ b/app/lib/pages/conversations/widgets/conversation_list_item.dart @@ -16,6 +16,8 @@ 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/services/experiments/home_redesign.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/usage_provider.dart'; import 'package:omi/ui/ui.dart'; @@ -176,7 +178,12 @@ class _ConversationListItemState extends State { Future _open(BuildContext context, ConversationProvider provider) async { if (widget.conversation.isLocked) { if (!context.read().showSubscriptionUI) return; - routeToPage(context, const UsagePage()); + // home-redesign: the paywall opens in place; today's Home goes to Usage. + if (HomeRedesign.on) { + await openUpgradePaywall(context, source: 'locked_conversation'); + } else { + routeToPage(context, const UsagePage()); + } return; } HapticFeedback.selectionClick(); @@ -427,7 +434,7 @@ class _ConversationListItemState extends State { if (!widget.conversation.isLocked || widget.inLockedRun) return content; return OmiLockedPreview( status: context.l10n.statusPending, - label: context.l10n.viewUsage, + label: HomeRedesign.on ? context.l10n.upgrade : context.l10n.viewUsage, onPressed: onTap, child: content, ); @@ -855,7 +862,12 @@ class LockedConversationRun extends StatelessWidget { return; } if (!context.read().showSubscriptionUI) return; - routeToPage(context, const UsagePage()); + // home-redesign: the paywall opens in place; today's Home goes to Usage. + if (HomeRedesign.on) { + await openUpgradePaywall(context, source: 'locked_conversation'); + } else { + routeToPage(context, const UsagePage()); + } } @override @@ -867,7 +879,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: HomeRedesign.on ? context.l10n.upgrade : context.l10n.viewUsage, 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..f306150903d 100644 --- a/app/lib/pages/conversations/widgets/empty_conversations.dart +++ b/app/lib/pages/conversations/widgets/empty_conversations.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; @@ -17,7 +18,8 @@ class NoConversationsHero extends StatelessWidget { return Padding( padding: const EdgeInsets.only(bottom: 120), child: OmiEmptyState( - icon: Icons.forum_rounded, + // home-redesign: text only; today's Home keeps the icon. + icon: HomeRedesign.on ? null : Icons.forum_rounded, title: context.l10n.noConversationsYet, titleLayoutReference: context.l10n.noTasksYet, message: message, @@ -47,7 +49,7 @@ class EmptyConversationsWidget extends StatelessWidget { title: l10n.noStarredConversations, message: l10n.starConversationHint, ) - : OmiEmptyState(icon: Icons.forum_rounded, title: l10n.noConversationsYet), + : OmiEmptyState(icon: HomeRedesign.on ? null : Icons.forum_rounded, title: l10n.noConversationsYet), ); } } diff --git a/app/lib/pages/conversations/widgets/processing_capture.dart b/app/lib/pages/conversations/widgets/processing_capture.dart index 634abe84259..2877f3fdff8 100644 --- a/app/lib/pages/conversations/widgets/processing_capture.dart +++ b/app/lib/pages/conversations/widgets/processing_capture.dart @@ -14,6 +14,7 @@ import 'package:omi/utils/l10n_extensions.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/providers/capture_provider.dart'; import 'package:omi/providers/device_provider.dart'; import 'package:omi/utils/alerts/app_snackbar.dart'; @@ -26,11 +27,16 @@ 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(); } @@ -112,6 +118,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 @@ -268,51 +276,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) { @@ -377,27 +346,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>[ diff --git a/app/lib/pages/home/legacy/legacy_home.dart b/app/lib/pages/home/legacy/legacy_home.dart new file mode 100644 index 00000000000..24b270ddd3d --- /dev/null +++ b/app/lib/pages/home/legacy/legacy_home.dart @@ -0,0 +1,333 @@ +// Today's Home, kept whole for the `home-redesign` experiment's control arm (see +// services/experiments/home_redesign.dart). Copied from main as it was before #21711; delete this +// folder once the experiment is decided. + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/backend/schema/phone_call.dart'; +import 'package:omi/pages/conversation_capturing/page.dart'; +import 'package:omi/pages/home/widgets/battery_info_widget.dart' show RecordOptionsSheet, PendantListeningSheet; +import 'package:omi/pages/phone_calls/active_call_page.dart'; +import 'package:omi/pages/phone_calls/phone_calls_feature.dart'; +import 'package:omi/pages/phone_calls/phone_calls_page.dart'; +import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/phone_call_provider.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/enums.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; +import 'package:omi/widgets/capture_sources.dart'; + +/// Today's chat bar height (the redesign's bottom controls are 56). +const double _legacyChatBarHeight = 62; + +const _stopGlyphRadius = BorderRadius.all(Radius.circular(4)); // omi-ux-allow: radius-literal -- a glyph + +/// Today's Ask Omi bar: the full-width chat field with a mic at its end. +class LegacyHomeChatBar extends StatelessWidget { + const LegacyHomeChatBar({super.key, required this.onTap, required this.onVoice}); + + final VoidCallback onTap; + final VoidCallback onVoice; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + button: true, + label: context.l10n.askOmi, + onTap: onTap, + child: GestureDetector( + key: const ValueKey('home_ask_omi_bar'), + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: Container( + height: _legacyChatBarHeight, + 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: () => onVoice(), + child: Semantics( + container: true, + button: true, + label: context.l10n.voiceMode, + child: Container( + height: _legacyChatBarHeight, + 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), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 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. +class LegacyHomeRecordButton extends StatefulWidget { + const LegacyHomeRecordButton({super.key}); + + @override + State createState() => _LegacyHomeRecordButtonState(); +} + +class _LegacyHomeRecordButtonState extends State { + static const _optionsTipKey = 'v2/homeRecordOptionsTipShown'; + + void _showRecordOptions(BuildContext context) { + OmiHaptics.light(); + SharedPreferencesUtil().saveBool(_optionsTipKey, true); + showOmiSheet( + context: context, + title: context.l10n.recordWith, + builder: (sheetContext) => RecordOptionsSheet( + onPickPhoneMic: () { + Navigator.pop(sheetContext); + _startRecording(context); + }, + onPickPhoneCall: () { + Navigator.pop(sheetContext); + if (!context.mounted) return; + routeToPage(context, const PhoneCallsPage()); + }, + ), + ); + } + + /// Once, after the first recording stops: say that holding the button offers more ways to record. + void _maybeShowOptionsTip(BuildContext context) { + if (!PhoneCallsFeature.visible) return; + final prefs = SharedPreferencesUtil(); + if (prefs.getBool(_optionsTipKey)) return; + prefs.saveBool(_optionsTipKey, true); + OmiFeedback.info(context, context.l10n.recordOptionsTip); + } + + static bool _callInProgress(BuildContext context) { + final state = context.read().callState; + return state == PhoneCallState.connecting || state == PhoneCallState.ringing || state == PhoneCallState.active; + } + + /// The pendant is recording (or paused) in realtime mode: explain, and let the user choose. + /// A Transcribe Later pendant is excluded — its capture can't be taken over at all, so it + /// falls through to the refusal feedback rather than offering a choice that would fail. + static bool _pendantHasCapture(CaptureProvider capture) { + final source = capture.liveCaptureSource; + return source != null && + source != 'phone' && + !SharedPreferencesUtil().batchModeEnabled && + !capture.isPendantBatchRecording; + } + + void _showPendantListening(BuildContext context) { + OmiHaptics.light(); + showOmiSheet( + context: context, + title: context.read().pendantCaptureVerified + ? context.l10n.pendantIsListeningTitle + : CaptureSources.label(context, 'omi'), + builder: (sheetContext) => PendantListeningSheet( + onRecordWithPhone: () { + Navigator.pop(sheetContext); + _startPhoneRecording(context); + }, + onPhoneCall: PhoneCallsFeature.visible + ? () { + Navigator.pop(sheetContext); + if (context.mounted) routeToPage(context, const PhoneCallsPage()); + } + : null, + onKeepPendant: () => Navigator.pop(sheetContext), + ), + ); + } + + Future _startRecording(BuildContext context) async { + final captureProvider = context.read(); + if (captureProvider.recordingState == RecordingState.initialising) return; + // An Omi call owns the capture while it runs: the button opens the call, never a recording. + if (_callInProgress(context)) { + routeToPage(context, const ActiveCallPage()); + return; + } + if (_pendantHasCapture(captureProvider) && !captureProvider.isPhoneMicPaused) { + _showPendantListening(context); + return; + } + await _startPhoneRecording(context); + } + + Future _startPhoneRecording(BuildContext context) async { + final captureProvider = context.read(); + OmiHaptics.medium(); + if (captureProvider.recordingState == RecordingState.record || captureProvider.isPhoneMicPaused) { + // 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(); + PlatformManager.instance.analytics.phoneMicRecordingStopped(); + if (context.mounted) _maybeShowOptionsTip(context); + return; + } + if (captureProvider.isPendantBatchRecording) { + if (context.mounted) { + OmiFeedback.info(context, context.l10n.phoneRecordingBlockedByPendantBatch); + } + return; + } + try { + await captureProvider.streamRecording(); + } catch (_) { + if (context.mounted) { + OmiFeedback.error(context, context.l10n.somethingWentWrong); + } + return; + } + if (captureProvider.liveCaptureSource != 'phone') return; + PlatformManager.instance.analytics.phoneMicRecordingStarted(); + // Phone-mic Transcribe Later (batch) has no live transcript — its surface is the + // conversations-list batch card, so skip the capturing page (same as BLE batch). + if (captureProvider.isPhoneMicBatchRecording) { + if (SharedPreferencesUtil().phoneBatchAuto && context.mounted) { + OmiFeedback.info(context, context.l10n.phoneMicOfflineFallbackMessage); + } + return; + } + if (context.mounted) { + routeToPage(context, ConversationCapturingPage(topConversationId: captureProvider.topConversationId)); + } + } + + @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), + ), + ), + ), + ), + ), + ]), + ); + }, + ); + } +} diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 50c6def2c0e..bdeeda02a94 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -22,7 +22,12 @@ 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/legacy/legacy_home.dart'; +import 'package:omi/services/experiments/home_redesign.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'; @@ -369,6 +374,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Reload convos if (mounted) { + // home-redesign: an arm PostHog assigned since launch is saved now (applied next launch). + HomeRedesign.start(); Provider.of(context, listen: false).refreshConversations(); final captureProvider = Provider.of(context, listen: false); captureProvider.setMetricsAppActive(true); @@ -432,6 +439,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), ); SharedPreferencesUtil().onboardingCompleted = true; + // home-redesign experiment: this account's saved arm now, the flag read in the background, and + // the exposure once Home is on screen. + HomeRedesign.start(); + WidgetsBinding.instance.addPostFrameCallback((_) => HomeRedesign.markHomeShown()); if (!SharedPreferencesUtil().permissionsCompleted) { SharedPreferencesUtil().permissionsCompleted = true; } @@ -474,6 +485,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); @@ -564,6 +576,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker }, ); + // Register callback for device connection to check firmware announcements and device onboarding deviceProvider.onDeviceConnected = (BtDevice device) { _onDeviceConnectedForAnnouncements(device); @@ -762,104 +775,121 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker }, child: Selector( selector: (_, homeProvider) => homeProvider.selectedIndex, - builder: (context, selectedIndex, _) { - final onHome = selectedIndex == HomeProvider.homeTab; - // D6: Android back on Tasks returns to Home before it leaves the app. - return PopScope( - canPop: onHome, - onPopInvokedWithResult: (didPop, _) { - if (didPop || onHome) return; - OmiHaptics.selection(); - context.read().setIndex(HomeProvider.homeTab); - _schedulePageInitialization(HomeProvider.homeTab); - }, - child: Scaffold( - backgroundColor: OmiColors.surface0, - resizeToAvoidBottomInset: false, - appBar: _buildAppBar(context, onHome), - body: GestureDetector( - onTap: () => primaryFocus?.unfocus(), - child: Stack( - children: [ - Column( - children: [ - HomeTabSwitcher( - trailing: onHome ? null : _buildTasksActions(context), - onTabTap: (index, isRepeat) { - if (isRepeat) { - _scrollToTop(index); - } else { - // Change pages immediately. If background prewarming has not - // finished, the destination paints a skeleton for one frame. - context.read().setIndex(index); - _schedulePageInitialization(index); + builder: (context, selectedIndex, _) => ValueListenableBuilder( + // home-redesign experiment: the redesigned Home, or today's (control). + valueListenable: HomeRedesign.listenable, + builder: (context, redesign, _) { + final onHome = selectedIndex == HomeProvider.homeTab; + // D6: Android back on Tasks returns to Home before it leaves the app. + return PopScope( + canPop: onHome, + onPopInvokedWithResult: (didPop, _) { + if (didPop || onHome) return; + OmiHaptics.selection(); + context.read().setIndex(HomeProvider.homeTab); + _schedulePageInitialization(HomeProvider.homeTab); + }, + child: Scaffold( + backgroundColor: OmiColors.surface0, + resizeToAvoidBottomInset: false, + appBar: _buildAppBar(context, showCalendar: !redesign && onHome), + body: GestureDetector( + onTap: () => primaryFocus?.unfocus(), + child: Stack( + children: [ + Column( + children: [ + HomeTabSwitcher( + // Tasks' menu (select, group, completed, export) sits here in both arms; today's + // Home keeps the calendar in the header instead. + trailing: redesign || !onHome ? _buildPageActions(context, onHome) : null, + onTabTap: (index, isRepeat) { + if (isRepeat) { + _scrollToTop(index); + } else { + // Change pages immediately. If background prewarming has not + // finished, the destination paints a skeleton for one frame. + context.read().setIndex(index); + _schedulePageInitialization(index); + } + }, + ), + if (onHome) + Selector( + selector: (_, provider) => (provider.selectedStartDate, provider.selectedEndDate), + builder: (context, range, _) { + final start = range.$1; + if (start == null) return const SizedBox.shrink(); + final provider = context.read(); + return Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.md, 0, OmiSpacing.md, OmiSpacing.xs), + child: Align( + alignment: Alignment.centerLeft, + child: OmiDateFilterChip( + key: const ValueKey('home_date_filter'), + start: start, + end: range.$2, + onClear: () => unawaited(provider.clearDateFilter()), + ), + ), + ); + }, + ), + // Home shows an active call in its capture row; Tasks gets the slim bar. + if (!onHome) const ActiveCallTopBar(), + Expanded( + child: redesign + ? HomePageStack(index: selectedIndex, children: _buildPages(selectedIndex)) + : IndexedStack(index: selectedIndex, children: _buildPages(selectedIndex)), + ), + ], + ), + if (onHome) + Consumer( + builder: (context, home, child) { + if (home.isChatFieldFocused || home.isMemoriesSearchFieldFocused) { + return const SizedBox.shrink(); } + return child!; }, - ), - if (onHome) - Selector( - selector: (_, provider) => (provider.selectedStartDate, provider.selectedEndDate), - builder: (context, range, _) { - final start = range.$1; - if (start == null) return const SizedBox.shrink(); - final provider = context.read(); - return Padding( - padding: const EdgeInsets.fromLTRB(OmiSpacing.md, 0, OmiSpacing.md, OmiSpacing.xs), - child: Align( - alignment: Alignment.centerLeft, - child: OmiDateFilterChip( - key: const ValueKey('home_date_filter'), - start: start, - end: range.$2, - onClear: () => unawaited(provider.clearDateFilter()), - ), + child: Stack( + children: [ + const HomeChatBarBackdrop(), + Positioned( + left: 16, + right: 16, + bottom: homeChatBarOffset(context), + child: Row( + children: redesign + ? [ + HomeChatButton(onTap: _openChat, onLongPress: () => _openChat(voice: true)), + const SizedBox(width: 10), + const Expanded(child: HomeRecordButton()), + ] + : [ + Expanded( + child: LegacyHomeChatBar( + onTap: _openChat, onVoice: () => _openChat(voice: true)), + ), + const SizedBox(width: 10), + const LegacyHomeRecordButton(), + ], ), - ); - }, - ), - // 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)), - ), - ], - ), - if (onHome) - Consumer( - builder: (context, home, child) { - if (home.isChatFieldFocused || home.isMemoriesSearchFieldFocused) { - return const SizedBox.shrink(); - } - return child!; - }, - child: Stack( - children: [ - const HomeChatBarBackdrop(), - Positioned( - left: 16, - right: 16, - bottom: homeChatBarOffset(context), - child: Row( - children: [ - Expanded(child: _buildChatBar(context)), - const SizedBox(width: 10), - const HomeRecordButton(), - ], ), - ), - ], + ], + ), ), - ), - // Merge action bar - replaces the chat bar while conversations are selected - if (onHome) const Positioned(left: 0, right: 0, bottom: 0, child: MergeActionBar()), - // Task selection action bar - at the bottom of Tasks while tasks are selected. - if (!onHome) const Positioned(left: 0, right: 0, bottom: 0, child: TaskSelectionActionBar()), - ], + // Merge action bar - replaces the chat bar while conversations are selected + if (onHome) const Positioned(left: 0, right: 0, bottom: 0, child: MergeActionBar()), + // Task selection action bar - at the bottom of Tasks while tasks are selected. + if (!onHome) const Positioned(left: 0, right: 0, bottom: 0, child: TaskSelectionActionBar()), + ], + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ); @@ -879,99 +909,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(), ); } @@ -992,7 +946,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ); } - PreferredSizeWidget _buildAppBar(BuildContext context, bool onHome) { + /// The shell header: device, search, Activity and settings, the same on Home and Tasks. Page actions sit + /// beside the switcher instead (see [_buildPageActions]). + PreferredSizeWidget _buildAppBar(BuildContext context, {bool showCalendar = false}) { return AppBar( automaticallyImplyLeading: false, backgroundColor: Theme.of(context).colorScheme.surface, @@ -1013,7 +969,8 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker ), Row( children: [ - if (onHome) + // home-redesign control: today's Home keeps the calendar in the header. + if (showCalendar) HeaderCircleButton( key: const ValueKey('home_calendar_button'), semanticLabel: context.l10n.filterByDate, @@ -1030,8 +987,9 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker icon: Icon(Icons.search, size: 20, color: OmiColors.textSecondary), onTap: () => unawaited(_openSearch()), ), - // Activity: what Omi did on its own; the dot only for something new worth opening. - if (onHome) const ActivityBellButton(), + // Activity: what Omi did on its own; the dot only for something new worth opening. In the + // shell header, so the bar is the same on Home and Tasks. + const ActivityBellButton(), // Settings button - always visible HeaderCircleButton( semanticLabel: context.l10n.settings, diff --git a/app/lib/pages/home/widgets/battery_info_widget.dart b/app/lib/pages/home/widgets/battery_info_widget.dart index f6bdb18b52f..55549e827f7 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}); @@ -180,15 +183,13 @@ 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), - Flexible( - child: Text(label, - maxLines: 1, overflow: TextOverflow.ellipsis, style: OmiType.caption.copyWith(fontSize: 12)), - ), - ], + // The word stays on every page (the logo alone reads as a loading spinner); it gives + // way with an ellipsis so the header fits narrow screens and large text. + const SizedBox(width: 6), + Flexible( + child: Text(label, + maxLines: 1, overflow: TextOverflow.ellipsis, style: OmiType.caption.copyWith(fontSize: 12)), + ), ], ); } @@ -230,10 +231,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}); @@ -244,6 +248,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); @@ -330,10 +345,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; @@ -362,104 +383,246 @@ 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); + // Only ever finishes: the recording may have ended while the sheet was open, and + // the record toggle would then start a new one. + if (context.mounted && _phoneOwnsCapture(context.read())) { + _startPhoneRecording(context); + } + } + : null, + ), + ); + } + + void _showDroppedSheet(BuildContext context) { + showOmiSheet( + context: context, + title: context.l10n.disconnected, + builder: (sheetContext) => HomeCaptureSheet( + explanation: context.l10n.capturePendantDisconnectedDetail, + showCard: false, + // The pendant may be gone for a while: recording with the phone stays one tap away. + onRecordWithPhone: () { + Navigator.pop(sheetContext); + if (context.mounted) _startPhoneRecording(context); + }, + ), + ); + } + + 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 pendantConnecting = context.select((d) => d.isConnecting); + 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}, + // While the microphone is still starting, the transcript opens by itself when it is up. + onTap: _stopping || starting + ? 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 status = + '${l10n.disconnected} · ${pendantConnecting ? 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); + }, + // Holding it records with the phone instead, as + New would. + onLongPress: () => _startPhoneRecording(context), + longPressHint: l10n.recordWith, + 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..1af33f736ce --- /dev/null +++ b/app/lib/pages/home/widgets/home_capture_sheet.dart @@ -0,0 +1,79 @@ +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.onRecordWithPhone, + this.showCard = true, + }); + + final String? explanation; + final VoidCallback? onOpenTranscript; + final VoidCallback? onStop; + + /// A pendant that dropped: start a phone recording instead. + final VoidCallback? onRecordWithPhone; + + /// 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 (onRecordWithPhone != null) + OmiButton.secondary( + key: const ValueKey('home_capture_sheet_record_phone'), + label: l10n.recordWithPhoneInstead, + icon: Icons.mic_rounded, + onPressed: onRecordWithPhone, + 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..71f3f1ddda5 100644 --- a/app/lib/pages/memories/widgets/memory_item.dart +++ b/app/lib/pages/memories/widgets/memory_item.dart @@ -10,6 +10,8 @@ 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/services/experiments/home_redesign.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/memories_provider.dart'; import 'package:omi/providers/usage_provider.dart'; @@ -56,7 +58,12 @@ class MemoryItem extends StatelessWidget { showMemoryQuickEditSheet(context, memory, provider, readOnly: true); return; } - routeToPage(context, const UsagePage()); + // home-redesign: the paywall opens in place; today's Home goes to Usage. + if (HomeRedesign.on) { + openUpgradePaywall(context, source: 'locked_memory'); + } else { + routeToPage(context, const UsagePage()); + } return; } if (editable) { @@ -187,7 +194,9 @@ 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 + ? (HomeRedesign.on ? context.l10n.upgrade : context.l10n.viewUsage) + : context.l10n.saved, onPressed: openMemory, child: rowContent, ) 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..14acadcd50d 100644 --- a/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart +++ b/app/lib/pages/onboarding/variants/yinka/activation/activation_flow.dart @@ -6,8 +6,10 @@ import 'package:flutter/material.dart'; import 'package:in_app_review/in_app_review.dart'; import 'package:permission_handler/permission_handler.dart'; +import 'package:omi/services/experiments/home_redesign.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 +100,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 && HomeRedesign.on) NewTasksDot.show(); setState(() => _stage = next); } diff --git a/app/lib/pages/onboarding/wrapper.dart b/app/lib/pages/onboarding/wrapper.dart index 68db725cf33..46bc2d9e70e 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'; @@ -24,6 +26,7 @@ import 'package:omi/pages/onboarding/variants/yinka/yinka_onboarding_flow.dart'; import 'package:omi/pages/onboarding/web_onboarding_page.dart'; import 'package:omi/pages/paywall/paywall_router.dart'; import 'package:omi/services/experiments/onboarding_variant_gate.dart'; +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:omi/services/experiments/paywall_experiment.dart'; import 'package:omi/pages/onboarding/setup_page.dart'; import 'package:omi/pages/onboarding/speech_profile_widget.dart'; @@ -39,6 +42,11 @@ 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/providers/action_items_provider.dart'; import 'package:omi/utils/other/temp.dart'; import 'package:omi/app_globals.dart'; import 'package:omi/core/app_shell.dart'; @@ -265,6 +273,7 @@ class _OnboardingWrapperState extends State with TickerProvid SharedPreferencesUtil().remove(_resumeKey); _stepTelemetry.exit(); _completeOnboardingTelemetry(); + unawaited(_addStarterTasks(context.l10n, onAdded: context.read().forceRefreshActionItems)); PlatformManager.instance.analytics.onboardingCompleted(); PaintingBinding.instance.imageCache.clear(); if (!paywallShown) await _maybeShowOnboardingPaywall(); @@ -273,11 +282,49 @@ 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, {Future Function()? onAdded}) async { + // Part of the redesigned Home (home-redesign experiment). + if (!HomeRedesign.on) return; + 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(); + // Home's Tasks list may have loaded before these landed. + await onAdded?.call(); + } 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); } @@ -285,6 +332,8 @@ class _OnboardingWrapperState extends State with TickerProvid /// (Reid's web page, Yinka's flow) or the native step the reader left off at. A web page that /// falls back lands on that same native step. Future _enterSteps() async { + // Decide this account's Home (home-redesign experiment) while onboarding runs, before Home shows. + HomeRedesign.start(); final arm = await OnboardingVariantGate.resolve(); if (!mounted) return; PlatformManager.instance.analytics.track('Onboarding Variant Shown', properties: { diff --git a/app/lib/pages/paywall/paywall_router.dart b/app/lib/pages/paywall/paywall_router.dart index b9a553861e5..ee52039e685 100644 --- a/app/lib/pages/paywall/paywall_router.dart +++ b/app/lib/pages/paywall/paywall_router.dart @@ -52,6 +52,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..1582f839382 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -5,9 +5,11 @@ 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'; @@ -106,11 +108,17 @@ 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); } Widget _buildSettings(BuildContext context) { @@ -137,12 +145,14 @@ class _SettingsDrawerState extends State { // Plan, referrals and feedback stay one tap from the sheet (David, 2026-09-24). OmiSettingsGroup( children: [ - _row( - SettingsDestination.planAndUsage, - key: 'settings_row_planAndUsage', - icon: FontAwesomeIcons.chartLine, - title: l10n.planAndUsage, - value: planValue, + SettingsPlanSection( + row: _row( + SettingsDestination.planAndUsage, + key: 'settings_row_planAndUsage', + icon: FontAwesomeIcons.chartLine, + title: l10n.planAndUsage, + value: planValue, + ), ), _row( SettingsDestination.referral, 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..5bc8bf3be0f --- /dev/null +++ b/app/lib/pages/settings/widgets/settings_plan_section.dart @@ -0,0 +1,94 @@ +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/ui/ui.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +/// The Plan & Usage row on the Settings sheet, plus — for Free and Plus — this month's minutes and +/// an Upgrade button, so upgrading is one tap from Settings instead of two. +class SettingsPlanSection extends StatelessWidget { + const SettingsPlanSection({super.key, required this.row}); + + /// The Plan & Usage row; tapping it still opens the Plan & Usage page. + final Widget row; + + @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 row; + } + final l10n = context.l10n; + 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 (subscription.transcriptionSecondsLimit > 0) ...[ + _MinutesMeter(subscription: subscription), + const SizedBox(height: OmiSpacing.md), + ], + OmiButton( + key: const ValueKey('settings_plan_upgrade'), + label: plan == PlanType.plus ? l10n.upgradeToUnlimited : l10n.upgrade, + expand: true, + onPressed: () => openSettingsUpgrade(context, plan!), + ), + ], + ), + ), + ], + ); + } +} + +/// 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', + ); + +class _MinutesMeter extends StatelessWidget { + const _MinutesMeter({required this.subscription}); + + final UserSubscriptionResponse subscription; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final used = (subscription.transcriptionSecondsUsed / 60).round(); + final limit = (subscription.transcriptionSecondsLimit / 60).round(); + final ratio = (subscription.transcriptionSecondsUsed / subscription.transcriptionSecondsLimit).clamp(0.0, 1.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ], + ); + } +} 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..6e6059d30f0 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/home_redesign.dart b/app/lib/services/experiments/home_redesign.dart new file mode 100644 index 00000000000..9ec57ea0f22 --- /dev/null +++ b/app/lib/services/experiments/home_redesign.dart @@ -0,0 +1,134 @@ +import 'package:flutter/foundation.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/utils/analytics/analytics_manager.dart'; + +/// Home redesign A/B (PostHog flag `home-redesign`): `test` shows the redesigned Home (the bottom +/// recording pill with no top card, text-only empty states, pull-to-refresh, +/// page actions beside the switcher, upgrade in place, Tasks fold and menu, Omi's chat welcome, +/// starter to-dos); `control`, an unread flag or anything unexpected shows today's Home. +/// +/// Client-evaluated. The arm is kept per account once PostHog assigns it, so a reader never +/// switches Home mid-session or between launches; a new assignment only takes effect on a Home the +/// reader has not seen yet this session (otherwise from the next launch). Exposure is PostHog's +/// `$feature_flag_called`, once per account and arm, sent when Home is first shown. +/// +/// Pre-registration: `product/experiments/home-redesign.md`. +abstract final class HomeRedesign { + /// PostHog flag key (project 302298). Registered in `config/feature-flags.yaml`. + static const enabledFlag = 'home-redesign'; + static const testVariant = 'test'; + + /// Debug-build override: `--dart-define=OMI_HOME_REDESIGN=on|off`. + static const _debugOverride = String.fromEnvironment('OMI_HOME_REDESIGN'); + + static final ValueNotifier _on = ValueNotifier(false); + + /// Whether this reader sees the redesigned Home. Listen to rebuild when the arm is first known. + static ValueListenable get listenable => _on; + static bool get on => _on.value; + + static bool _homeShown = false; + static String? _loadedFor; + + static String _key(String uid) => 'experiments/home-redesign/arm/$uid'; + + static bool? get _debugArm => switch (kDebugMode ? _debugOverride : '') { + 'on' => true, + 'off' => false, + _ => null, + }; + + /// Applies the arm saved for the signed-in account (no network). Call at startup and after an + /// account change. + static void loadSaved({String? uid}) { + final debug = _debugArm; + if (debug != null) { + _on.value = debug; + return; + } + final id = uid ?? SharedPreferencesUtil().uid; + _loadedFor = id; + _on.value = id.isNotEmpty && SharedPreferencesUtil().getString(_key(id)) == testVariant; + } + + /// Reads the flag for the signed-in account and saves the arm. Never moves a Home the reader is + /// already looking at: a changed arm applies from the next launch. + static Future refresh({ + Future Function(String key)? readVariant, + String? Function()? identity, + String? uid, + Duration identityWait = Duration.zero, + }) async { + if (_debugArm != null) return; + final whoAmI = identity ?? () => AnalyticsManager().settledDistinctId; + // Right after sign-in the analytics identity is still settling; flags read before it belong to + // the anonymous id, where a per-account rollout never matches. + final deadline = DateTime.now().add(identityWait); + while (whoAmI() == null && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 200)); + } + final who = whoAmI(); + final id = uid ?? SharedPreferencesUtil().uid; + if (who == null || id.isEmpty) return; + if (_loadedFor != id) loadSaved(uid: id); + try { + final variant = await (readVariant ?? AnalyticsManager().getFeatureFlagVariant)(enabledFlag); + if (variant == null) return; // unresolved: keep what this account already has + if (whoAmI() != who) return; // account switched mid-read + SharedPreferencesUtil().saveString(_key(id), variant.trim()); + if (!_homeShown) _on.value = variant.trim() == testVariant; + } catch (_) { + // Keep the saved arm (or control) on any failure. + } + } + + static VoidCallback? _identityListener; + + /// Startup: the saved arm now, then the flag once the analytics identity settles (right after + /// sign-in it is still the anonymous id, where a per-account rollout never matches). Listens for + /// the identity instead of polling, so nothing keeps running. + static void start() { + loadSaved(); + if (AnalyticsManager().settledDistinctId != null) { + refresh(); + return; + } + if (_identityListener != null) return; + void onIdentity() { + if (AnalyticsManager().settledDistinctId == null) return; + AnalyticsManager.identityChanges.removeListener(_identityListener!); + _identityListener = null; + refresh(); + } + + _identityListener = onIdentity; + AnalyticsManager.identityChanges.addListener(onIdentity); + } + + /// Home is on screen: from now on the arm stays put this session, and the reader is exposed for the + /// arm they actually see (only once PostHog has assigned one). + static void markHomeShown({bool Function(String key, String variant)? record}) { + _homeShown = true; + final id = SharedPreferencesUtil().uid; + if (_debugArm != null || id.isEmpty) return; + final saved = SharedPreferencesUtil().getString(_key(id)); + if (saved.isEmpty) return; + (record ?? AnalyticsManager().recordFeatureFlagExposure)(enabledFlag, on ? testVariant : 'control'); + } + + @visibleForTesting + static void setForTesting(bool value) { + _on.value = value; + _homeShown = false; + } + + @visibleForTesting + static void resetForTesting() { + if (_identityListener != null) AnalyticsManager.identityChanges.removeListener(_identityListener!); + _identityListener = null; + _on.value = false; + _homeShown = false; + _loadedFor = null; + } +} 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/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/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/test/unit/home_redesign_test.dart b/app/test/unit/home_redesign_test.dart new file mode 100644 index 00000000000..2af5e363e17 --- /dev/null +++ b/app/test/unit/home_redesign_test.dart @@ -0,0 +1,89 @@ +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/home_redesign.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + HomeRedesign.resetForTesting(); + }); + + Future read(String? variant, {String uid = 'u1', String? who = 'd1'}) => HomeRedesign.refresh( + readVariant: (_) async => variant, + identity: () => who, + uid: uid, + identityWait: Duration.zero, + ); + + test('today\'s Home until PostHog assigns the test arm', () async { + HomeRedesign.loadSaved(uid: 'u1'); + expect(HomeRedesign.on, isFalse); + await read(null); + expect(HomeRedesign.on, isFalse, reason: 'an unread flag is control'); + await read('test'); + expect(HomeRedesign.on, isTrue); + }); + + test('control and unknown variants show today\'s Home', () async { + await read('control'); + expect(HomeRedesign.on, isFalse); + await read('surprise'); + expect(HomeRedesign.on, isFalse); + }); + + test('the arm is saved per account and applied at the next start without a read', () async { + await read('test', uid: 'u1'); + HomeRedesign.resetForTesting(); + HomeRedesign.loadSaved(uid: 'u1'); + expect(HomeRedesign.on, isTrue); + HomeRedesign.loadSaved(uid: 'someone-else'); + expect(HomeRedesign.on, isFalse, reason: 'another account has its own arm'); + }); + + test('never switches a Home already on screen; the new arm waits for the next launch', () async { + HomeRedesign.loadSaved(uid: 'u1'); + HomeRedesign.markHomeShown(record: (_, __) => true); + await read('test'); + expect(HomeRedesign.on, isFalse); + HomeRedesign.resetForTesting(); + HomeRedesign.loadSaved(uid: 'u1'); + expect(HomeRedesign.on, isTrue); + }); + + test('exposure is sent for the arm on screen, only once PostHog has assigned one', () async { + final exposures = []; + bool record(String key, String variant) { + exposures.add('$key=$variant'); + return true; + } + + SharedPreferencesUtil().uid = 'u1'; + HomeRedesign.loadSaved(uid: 'u1'); + HomeRedesign.markHomeShown(record: record); + expect(exposures, isEmpty, reason: 'no assignment yet'); + + await read('test'); + HomeRedesign.resetForTesting(); + HomeRedesign.loadSaved(uid: 'u1'); + HomeRedesign.markHomeShown(record: record); + expect(exposures, ['home-redesign=test']); + }); + + test('no identity yet: nothing is read or saved', () async { + var reads = 0; + await HomeRedesign.refresh( + readVariant: (_) async { + reads++; + return 'test'; + }, + identity: () => null, + uid: 'u1', + identityWait: Duration.zero, + ); + expect(reads, 0); + expect(HomeRedesign.on, isFalse); + }); +} diff --git a/app/test/unit/paywall_experiment_test.dart b/app/test/unit/paywall_experiment_test.dart index af98b835d13..d76ea7fe6ae 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/widgets/action_items_row_gestures_test.dart b/app/test/widgets/action_items_row_gestures_test.dart index 08b164f5a22..6ace3b58374 100644 --- a/app/test/widgets/action_items_row_gestures_test.dart +++ b/app/test/widgets/action_items_row_gestures_test.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:omi/services/experiments/home_redesign.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'; @@ -33,6 +36,10 @@ Future _items({ /// Swipes mean the same thing on every task row (hub #4) and deletes offer Undo (hub #3, D5). void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + setUp(() { PlatformManager.initializeForLocalHarness(); // Indent/sort persistence is not injectable; point it at a dead loopback port (the test @@ -68,11 +75,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..6f0d4e46cef 100644 --- a/app/test/widgets/capture_home_ui_test.dart +++ b/app/test/widgets/capture_home_ui_test.dart @@ -2,6 +2,8 @@ // button always means "record with this phone", and the header chip shows the battery only. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -137,6 +139,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++; @@ -192,6 +197,10 @@ class _Connectivity extends ChangeNotifier implements ConnectivityProvider { } void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + final en = lookupAppLocalizations(const Locale('en')); setUp(() async { @@ -548,10 +557,127 @@ 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); + // The pendant may be gone a while: the phone stays one tap away. + expect(find.byKey(const ValueKey('home_capture_sheet_record_phone')), 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 +685,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 +696,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 +724,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 +734,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 +744,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..8d39085ec28 --- /dev/null +++ b/app/test/widgets/chat_welcome_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.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() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + + 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..fcbd2d9916e 100644 --- a/app/test/widgets/conversation_locked_preview_test.dart +++ b/app/test/widgets/conversation_locked_preview_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -8,14 +10,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 = >[]; @@ -32,6 +33,10 @@ class _Usage extends UsageProvider { } void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + late ConversationProvider conversations; late UsageProvider usage; @@ -150,16 +155,27 @@ 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()); + }); + + testWidgets("today's Home (home-redesign control): View Usage, and the action opens the Usage page", + (tester) async { + HomeRedesign.setForTesting(false); + final routes = _Routes(); + await pumpRow(tester, observer: routes); + expect(find.text('View Usage'), findsOneWidget); + expect(find.text(_upgrade), findsNothing); + await tester.tap(find.byKey(const Key('locked_preview_action'))); + expect(routes.pushed, hasLength(2)); + expect(routes.pushed.last, isNot(isA())); await tester.pumpWidget(const SizedBox.shrink()); }); @@ -202,10 +218,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 +277,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 +301,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/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_calendar_filter_test.dart b/app/test/widgets/home_calendar_filter_test.dart index 97326e78c02..ef8872ec178 100644 --- a/app/test/widgets/home_calendar_filter_test.dart +++ b/app/test/widgets/home_calendar_filter_test.dart @@ -4,6 +4,8 @@ import 'package:firebase_core_platform_interface/test.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:nested/nested.dart'; import 'package:provider/provider.dart'; @@ -35,6 +37,10 @@ import 'package:omi/services/services.dart'; import '../../integration_test/journeys/support/hermetic_boot.dart'; void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + TestWidgetsFlutterBinding.ensureInitialized(); testWidgets('Home shows the calendar button; one picked day opens the day page', (tester) async { diff --git a/app/test/widgets/home_empty_conversations_test.dart b/app/test/widgets/home_empty_conversations_test.dart index d77f83a4975..a18f3103207 100644 --- a/app/test/widgets/home_empty_conversations_test.dart +++ b/app/test/widgets/home_empty_conversations_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:http/http.dart' as http; import 'package:omi/backend/http/api_presentation.dart'; import 'package:omi/backend/http/conversation_api_contract.dart'; @@ -10,8 +12,12 @@ import 'package:omi/ui/ui.dart'; import '../support/typed_conversation_screen.dart'; void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + 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 +50,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 +63,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 +78,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/offline_sync_storage_contrast_test.dart b/app/test/widgets/offline_sync_storage_contrast_test.dart index b8ace53e2d5..a81435234e2 100644 --- a/app/test/widgets/offline_sync_storage_contrast_test.dart +++ b/app/test/widgets/offline_sync_storage_contrast_test.dart @@ -1,6 +1,8 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/conversations/widgets/empty_conversations.dart'; import 'package:omi/pages/conversations/widgets/offline_sync_storage_sheet.dart'; @@ -14,6 +16,10 @@ double contrast(Color foreground, Color background) { } void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + for (final brightness in Brightness.values) { testWidgets('Offline Sync storage labels are readable in $brightness', (tester) async { final previous = OmiColors.active; @@ -125,7 +131,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..8f0211116d9 --- /dev/null +++ b/app/test/widgets/settings/settings_plan_section_test.dart @@ -0,0 +1,98 @@ +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'; + +/// The Settings plan section adds this month's minutes and an Upgrade button under the Plan & +/// Usage row for Free and Plus, and leaves the row alone 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 main() { + final en = lookupAppLocalizations(const Locale('en')); + const row = Text('Plan row', key: ValueKey('plan_row')); + + Future pump(WidgetTester tester, UsageProvider usage) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: usage, + child: const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: Locale('en'), + home: Scaffold(body: SettingsPlanSection(row: row)), + ), + ), + ); + } + + testWidgets('Free shows minutes used and Upgrade', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic, usedSeconds: 240 * 60, limitSeconds: 300 * 60))); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.text(en.minsUsedThisMonth('240', 300)), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.text(en.upgrade), findsOneWidget); + }); + + 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/task_completion_save_result_test.dart b/app/test/widgets/task_completion_save_result_test.dart index d72573b4237..c5a3a0bf183 100644 --- a/app/test/widgets/task_completion_save_result_test.dart +++ b/app/test/widgets/task_completion_save_result_test.dart @@ -5,6 +5,8 @@ library; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -58,6 +60,10 @@ ActionItemsProvider _provider({required bool accept}) { } void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + late _RecordingAdapter adapter; setUp(() async { @@ -101,6 +107,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 +122,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 +137,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..40ebbe0a9f5 100644 --- a/app/test/widgets/tasks_empty_state_test.dart +++ b/app/test/widgets/tasks_empty_state_test.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:http/http.dart' as http; import 'package:omi/backend/http/action_items_api_contract.dart'; import 'package:omi/backend/http/api_presentation.dart'; @@ -12,6 +14,10 @@ import '../support/typed_action_items_screen.dart'; import '../support/typed_conversation_screen.dart'; void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + for (final brightness in Brightness.values) { testWidgets('empty Tasks keeps conversation guidance and aligns with Home in $brightness', (tester) async { final previousPalette = OmiColors.active; @@ -47,16 +53,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 +74,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 +106,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..19e394b8774 --- /dev/null +++ b/app/test/widgets/voice_profile_card_test.dart @@ -0,0 +1,93 @@ +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:omi/services/experiments/home_redesign.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() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + + 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..d67420e86e2 100644 --- a/app/test/widgets/waiting_memory_preview_test.dart +++ b/app/test/widgets/waiting_memory_preview_test.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/services/experiments/home_redesign.dart'; import 'package:provider/provider.dart'; 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'; @@ -24,6 +25,10 @@ class _Routes extends NavigatorObserver { } void main() { + // These cover the redesigned Home (home-redesign test arm). + setUp(() => HomeRedesign.setForTesting(true)); + tearDown(HomeRedesign.resetForTesting); + testWidgets('short waiting memory fits its status and action at 200% text', (tester) async { final provider = MemoriesProvider(); final usage = UsageProvider(); @@ -53,7 +58,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 +115,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 e424b446202..a48fee0e8f8 100644 --- a/backend/docs/feature-flag-registry.md +++ b/backend/docs/feature-flag-registry.md @@ -109,6 +109,7 @@ entries are exempt: they are queued for removal, not running. | `MEMORY_OWNER_JEV_SHADOW_PERCENT` | dazheng | [backend/docs/experiments/EXP-004-jev-relevance-owner-ramp.md](../../backend/docs/experiments/EXP-004-jev-relevance-owner-ramp.md) | 2026-10-21 | | `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 | +| `home-redesign` | yinka | [product/experiments/home-redesign.md](../../product/experiments/home-redesign.md) | 2026-11-14 | | `onboarding-variant` | nik | [product/experiments/onboarding-variant.md](../../product/experiments/onboarding-variant.md) | 2026-11-13 | | `paywall-variant` | nik | [product/experiments/paywall-variant.md](../../product/experiments/paywall-variant.md) | 2026-11-12 | @@ -153,6 +154,7 @@ and an explicit empty literal renders as `''`. | `MEMORY_OWNER_JEV_SHADOW_PERCENT` | Candidate-hash percentage for advisory owner measurement | backend | env | closed | 100 (cloud_run/backend, gke/backend-listen, gke/pusher); declared (cloud_run/backend-sync, cloud_run/backend-sync-backfill) | 0 (cloud_run/backend-sync, cloud_run/backend-sync-backfill); 100 (backend-listen (chart), cloud_run/backend, gke/backend-listen, gke/pusher, pusher (chart)) | 100 (backend-listen (chart), cloud_run/backend, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher, pusher (chart)) | — | pending | 2026-10-21 | dazheng | | `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), cloud_run/backend, cloud_run/backend-integration, cloud_run/backend-sync, cloud_run/backend-sync-backfill, gke/backend-listen, gke/pusher, pusher (chart)) | — | 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 | +| `home-redesign` | Mobile Home redesign A/B: test shows the redesigned Home from #21711 (one recording pill with no card above the list, condensing processing row, text-only empty states, pull-to-refresh, page actions beside the switcher, locked rows that upgrade in place, Tasks fold, chat welcome, starter to-dos); control, an unread flag or an unknown variant show today's Home. Client-evaluated; the arm is saved per account and never switches a Home already on screen | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-14 | yinka | | `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-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 | diff --git a/config/feature-flags.yaml b/config/feature-flags.yaml index 6913a1e7912..f72f994aded 100644 --- a/config/feature-flags.yaml +++ b/config/feature-flags.yaml @@ -52,6 +52,7 @@ flags: - {"key":"OMI_MESSAGING_RETENTION_DAYS","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Hourly messaging maintenance retains done inbox markers and unknown identities for 30 days by default; minimum seven; does not send.","fail":"closed","owner":"dazheng","created":"2026-10-14","decision":"keep"} - {"key":"OWNER_IDENTITY_RETRY_COALESCE_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Default false; bounded per-UID late identity coalescing, 16 cohorts, 128 candidates and eight eligible passes per cohort; two workers unchanged","fail":"closed","owner":"dazheng","created":"2026-10-14","review_by":"2026-11-13","decision":"pending"} - {"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":"home-redesign","prereg":"product/experiments/home-redesign.md","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile Home redesign A/B: test shows the redesigned Home from #21711 (one recording pill with no card above the list, condensing processing row, text-only empty states, pull-to-refresh, page actions beside the switcher, locked rows that upgrade in place, Tasks fold, chat welcome, starter to-dos); control, an unread flag or an unknown variant show today's Home. Client-evaluated; the arm is saved per account and never switches a Home already on screen","fail":"closed","owner":"yinka","created":"2026-10-15","review_by":"2026-11-14","decision":"pending","posthog":{"row":"expected","role":"exposure"},"notes":"PostHog flag 963799, experiment 486169 (draft, flag inactive: everyone sees today's Home until it launches; launch once the build with #21711 is in the stores). Debug builds: --dart-define=OMI_HOME_REDESIGN=on|off. Control path lives in app/lib/pages/home/legacy/ until decided."} - {"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":"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"} diff --git a/product/experiments/home-redesign.md b/product/experiments/home-redesign.md new file mode 100644 index 00000000000..7d7dc1b25a7 --- /dev/null +++ b/product/experiments/home-redesign.md @@ -0,0 +1,41 @@ +# Pre-registration: mobile Home redesign A/B (`home-redesign`) + +**Question.** Does the redesigned Home (from #21711) make new and existing mobile readers record +more, come back more and upgrade more than today's Home? + +**Arms** (PostHog flag `home-redesign`, id 963799, experiment 486169, 50/50 by distinct id; draft until the +build is in the stores): +- `control` — today's Home, unchanged: the Ask Omi chat bar with the round record button, the live + recording card above the list, empty states with icons, the calendar in the + header, locked rows that open Usage, no chat welcome or starter to-dos. +- `test` — the redesigned Home: Ask Omi beside one recording pill that carries every recording state + (no card above the list; details in a sheet), text-only empty + states, Omi pull-to-refresh, page actions beside the Home/Tasks switcher, locked rows that open + the paywall in place ("Upgrade"), the Tasks tick-and-fold, a closable voice-profile card, Omi's + first chat message, the Tasks dot and starter to-dos after onboarding. +Shared by both arms (not part of the comparison): the Activity bell and the one "on the way" row +for syncing and processing work (#21689, #21850), the +Tasks menu in the header, the Settings plan card. + +**Unit.** A signed-in reader who reaches Home. Exposure = `$feature_flag_called` with +`$feature_flag = home-redesign`, once per account and arm, when Home is first shown after PostHog has +assigned the arm. The arm is saved per account: a reader never switches Home mid-session; a new +assignment applies from the next launch if Home was already on screen. An unread flag is control +and is not exposed. + +**Primary metric.** Readers with ≥1 new conversation per exposed reader (funnel, 7-day window). + +**Secondary / guardrails.** `Upgrade Succeeded` per exposed reader (7 days); D7 retention (any app +open 7 days after exposure); `Paywall Opened` per reader; `Product Error` per reader (crash proxy). +Break every metric down by `$feature/home-redesign`. + +**Decision rule.** Minimum detectable effect 10% relative on the primary metric at PostHog's default +confidence; if `test` is worse than control by more than 20% relative on Product Error or D7 +retention, switch it to 0% regardless. Run until each arm has at least 2,000 exposed readers or 14 +days, whichever is later. When decided, delete the losing path (for `test` winning: +`app/lib/pages/home/legacy/`). + +**Kill switch.** Set the flag to control 100% (or deactivate it); every build shows today's Home +without a release. Debug builds: `--dart-define=OMI_HOME_REDESIGN=on|off`. + +**Owner.** yinka. Created 2026-10-15. Review by 2026-11-14. 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).