diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png b/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png new file mode 100644 index 00000000000..738a3cafa4b Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-settings-rework/001-mobile-ios-settings-before-light.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png b/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png new file mode 100644 index 00000000000..b2cbd372c5d Binary files /dev/null and b/.agent-artifacts/ui-evidence/yinka-settings-rework/002-mobile-ios-settings-after-light.png differ diff --git a/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json b/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json new file mode 100644 index 00000000000..4abfc1991f6 --- /dev/null +++ b/.agent-artifacts/ui-evidence/yinka-settings-rework/evidence.json @@ -0,0 +1,19 @@ +{ + "version": 1, + "images": [ + { + "file": "001-mobile-ios-settings-before-light.png", + "platform": "mobile-ios", + "description": "Settings sheet on main (widget-test render, Free plan): person icon on Account, plain Plan & Usage row with no Upgrade, Referral with a NEW tag beside it, one long group of eight rows", + "captured_by": "agent", + "source": "manual" + }, + { + "file": "002-mobile-ios-settings-after-light.png", + "platform": "mobile-ios", + "description": "Settings sheet after, iOS simulator (card arm, Free plan, no device): no handle band, Account as name and email, plan card with a full-width Upgrade, Device (Disconnected) / Recording / Notifications / Privacy first", + "captured_by": "agent", + "source": "manual" + } + ] +} diff --git a/app/changelog/unreleased/20261015-settings-order.json b/app/changelog/unreleased/20261015-settings-order.json new file mode 100644 index 00000000000..ffdf431de4f --- /dev/null +++ b/app/changelog/unreleased/20261015-settings-order.json @@ -0,0 +1,3 @@ +{ + "change": "Settings puts what you use most first, shows your device's battery, and makes upgrading one tap" +} diff --git a/app/docs/ux-contract.md b/app/docs/ux-contract.md index e0c7c3379b9..7e05f2913d7 100644 --- a/app/docs/ux-contract.md +++ b/app/docs/ux-contract.md @@ -66,6 +66,8 @@ There are exactly two ways out, and they mean different things. widget. It owns the top radius (`OmiRadius.xl`), the 36×4 drag handle, the optional title row with a trailing `OmiCloseButton`, safe-area and keyboard insets, and `isScrollControlled`. Never a raw `showModalBottomSheet` (`raw-bottom-sheet`) and never a hand-drawn handle. +- The Settings sheet drops the handle band (`showDragHandle: false`): its own header, with the close X, + is the top of the sheet, and it still swipes down to close. - A sheet nothing may dismiss (a required update) passes `isDismissible: false, enableDrag: false` and blocks back with `PopScope`, and has no handle that promises a swipe: `showOmiSheet` drops it itself; `showOmiSurfaceSheet` takes `showDragHandle: false`. diff --git a/app/lib/pages/settings/settings_drawer.dart b/app/lib/pages/settings/settings_drawer.dart index 086c3cb6fd9..98c1a2e3637 100644 --- a/app/lib/pages/settings/settings_drawer.dart +++ b/app/lib/pages/settings/settings_drawer.dart @@ -5,18 +5,21 @@ import 'package:provider/provider.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/pages/settings/settings_destinations.dart'; +import 'package:omi/models/subscription.dart'; import 'package:omi/pages/settings/settings_groups.dart'; -import 'package:omi/providers/usage_provider.dart'; import 'package:omi/pages/settings/widgets/plans/plan_display_name.dart'; +import 'package:omi/pages/settings/widgets/settings_plan_section.dart'; +import 'package:omi/providers/usage_provider.dart'; import 'package:omi/pages/settings/settings_search_index.dart'; import 'package:omi/providers/device_provider.dart'; import 'package:omi/ui/ui.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/platform/platform_service.dart'; -/// The Settings sheet: Account, then eight groups (Device, Recording & Transcription, Voice, -/// Notifications & Display, Integrations, Privacy & Data, Help & About, Developer Settings), plus -/// search over every row in Settings and its pages ([settingsSearchEntries]). +/// The Settings sheet: Account and Plan, then the groups people open most (Device, Recording & +/// Transcription, Notifications & Display, Data & Privacy), Voice, Integrations, Memories, Goals, +/// Help & About and Developer Settings, plus search over every row in Settings and its pages +/// ([settingsSearchEntries]). /// /// Every setting is at most one tap below the sheet: a group row opens its group page /// (settings_groups.dart), whose rows open the same pages the sheet used to open directly. @@ -30,12 +33,15 @@ class SettingsDrawer extends StatefulWidget { /// Opens Settings; resolves when the sheet closes (callers compare settings after that). static Future show(BuildContext context) { // Settings is a grouped list: surface1 rows on the page colour, so the sheet itself is surface0 - // (showOmiSheet paints surface1). Same shell otherwise: framework drag handle, own header with - // a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page + // (showOmiSheet paints surface1). Same shell otherwise, minus the handle band: its own header + // with a trailing close X. The surface is read on every rebuild, so switching Light/Dark from a page // opened here repaints the sheet too. return showOmiSurfaceSheet( context: context, surface: () => OmiColors.surface0, + // No handle band: the header (with its close X) is the top of the sheet, which still swipes + // down to close. + showDragHandle: false, builder: (context) => const FractionallySizedBox(heightFactor: 0.92, child: SettingsDrawer()), ); } @@ -83,7 +89,7 @@ class _SettingsDrawerState extends State { // --------------------------------------------------------------------------------------------- // Rows - /// A top-level row; [key] is the widget key a UI harness taps (`settings_account`, + /// A top-level row; [key] is the widget key a UI harness taps (`settings_row_`, /// `settings_group_`). Widget _row( SettingsDestination destination, { @@ -106,11 +112,24 @@ class _SettingsDrawerState extends State { ); } - /// Show the subscriber's actual tier on the Plan & Usage row. + /// The plan on the Plan & Usage row: "Free Plan" and the Plus plan name (their section adds an + /// Upgrade button), "Pro" for the other paid plans. String? _planValue(UsageProvider usage) { - final plan = usage.subscription?.subscription.plan; - if (plan == null || !plan.isPaid) return null; - return currentPlanDisplayName(context, usage.subscription); + final subscription = usage.subscription; + final plan = subscription?.subscription.plan; + if (plan == null) return null; + if (usage.showSubscriptionUI && (plan == PlanType.basic || plan == PlanType.plus)) { + return planDisplayName(plan, subscription!.availablePlans, context.l10n); + } + if (!plan.isPaid) return null; + return currentPlanDisplayName(context, subscription); + } + + /// The device on the Device row: its battery when it reports one, else Connected / Disconnected. + String _deviceValue(DeviceProvider device) { + if (!device.isConnected) return context.l10n.disconnected; + final battery = device.batteryLevel; + return battery >= 0 ? '$battery%' : context.l10n.connected; } Widget _buildSettings(BuildContext context) { @@ -124,36 +143,35 @@ class _SettingsDrawerState extends State { children: [ OmiSettingsGroup( children: [ - _row( - SettingsDestination.profile, - key: 'settings_account', - icon: FontAwesomeIcons.solidUser, - title: name.isEmpty ? l10n.account : name, - subtitle: email.isEmpty ? null : email, + _AccountHeader( + key: const ValueKey('settings_account'), + name: name.isEmpty ? l10n.account : name, + email: email.isEmpty ? null : email, + onTap: () => _open(SettingsDestination.profile), ), ], ), const SizedBox(height: OmiSpacing.xl), - // Plan, referrals and feedback stay one tap from the sheet (David, 2026-09-24). + // Plan stays one tap from the sheet (David, 2026-09-24); for Free and Plus it is a plan card + // with the minutes meter and the Upgrade button. OmiSettingsGroup( children: [ - _row( - SettingsDestination.planAndUsage, - key: 'settings_row_planAndUsage', - icon: FontAwesomeIcons.chartLine, - title: l10n.planAndUsage, - value: planValue, - ), - _row( - SettingsDestination.referral, - key: 'settings_row_referral', - icon: FontAwesomeIcons.gift, - title: l10n.referralProgram, - tag: SettingsTag(l10n.newTag, OmiColors.success), + SettingsPlanSection( + planName: planValue, + onOpen: () => _open(SettingsDestination.planAndUsage), + row: _row( + SettingsDestination.planAndUsage, + key: 'settings_row_planAndUsage', + icon: FontAwesomeIcons.chartLine, + title: l10n.planAndUsage, + value: planValue, + ), ), ], ), const SizedBox(height: OmiSpacing.xl), + // The groups people open most come first: Device and Recording & Transcription are most of + // the visits to Settings, then Notifications & Display and Data & Privacy. OmiSettingsGroup( children: [ _row( @@ -161,6 +179,7 @@ class _SettingsDrawerState extends State { key: 'settings_group_device', icon: FontAwesomeIcons.bluetooth, title: l10n.device, + value: _deviceValue(context.watch()), ), _row( SettingsDestination.recordingGroup, @@ -168,18 +187,29 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.microphone, title: l10n.recordingAndTranscription, ), - _row( - SettingsDestination.voice, - key: 'settings_group_voice', - icon: FontAwesomeIcons.volumeHigh, - title: l10n.assistantVoiceSettingsTitle, - ), _row( SettingsDestination.notificationsGroup, key: 'settings_group_notifications', icon: FontAwesomeIcons.solidBell, title: l10n.notificationsAndDisplay, ), + _row( + SettingsDestination.privacyGroup, + key: 'settings_group_privacy', + icon: FontAwesomeIcons.shield, + title: l10n.dataAndPrivacy, + ), + ], + ), + const SizedBox(height: OmiSpacing.xl), + OmiSettingsGroup( + children: [ + _row( + SettingsDestination.voice, + key: 'settings_group_voice', + icon: FontAwesomeIcons.volumeHigh, + title: l10n.assistantVoiceSettingsTitle, + ), _row( SettingsDestination.integrations, key: 'settings_group_integrations', @@ -193,12 +223,6 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.terminal, title: l10n.connectAnAgent, ), - _row( - SettingsDestination.privacyGroup, - key: 'settings_group_privacy', - icon: FontAwesomeIcons.shield, - title: l10n.dataAndPrivacy, - ), ], ), const SizedBox(height: OmiSpacing.xl), @@ -220,6 +244,7 @@ class _SettingsDrawerState extends State { ], ), const SizedBox(height: OmiSpacing.xl), + // Referrals and feedback stay one tap from the sheet (David, 2026-09-24). OmiSettingsGroup( children: [ _row( @@ -228,6 +253,12 @@ class _SettingsDrawerState extends State { icon: FontAwesomeIcons.circleQuestion, title: l10n.helpAndAbout, ), + _row( + SettingsDestination.referral, + key: 'settings_row_referral', + icon: FontAwesomeIcons.gift, + title: l10n.referralProgram, + ), if (PlatformService.isIntercomSupported) _row( SettingsDestination.feedback, @@ -324,19 +355,72 @@ class _SettingsDrawerState extends State { @override Widget build(BuildContext context) { final motion = OmiMotion.of(context); - return Column( - children: [ - AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)), - const SizedBox(height: OmiSpacing.xs), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg), - keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, - child: - _isSearching && _searchQuery.trim().isNotEmpty ? _buildSearchResults(context) : _buildSettings(context), + return Padding( + padding: const EdgeInsets.only(top: OmiSpacing.sm), + child: Column( + children: [ + AnimatedSwitcher(duration: motion.quick, child: _buildHeader(context)), + const SizedBox(height: OmiSpacing.xs), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.lg), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + child: _isSearching && _searchQuery.trim().isNotEmpty + ? _buildSearchResults(context) + : _buildSettings(context), + ), + ), + ], + ), + ); + } +} + +/// Who is signed in, at the top of the sheet: the name large with the email under it, like the plan +/// card below it. No picture: Omi has no profile photos, so a placeholder would only add noise. +/// Tapping it opens Account. +class _AccountHeader extends StatelessWidget { + const _AccountHeader({super.key, required this.name, required this.email, required this.onTap}); + + final String name; + final String? email; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, OmiSpacing.md, OmiSpacing.md, OmiSpacing.md), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(name, style: OmiType.title3, maxLines: 1, overflow: TextOverflow.ellipsis), + if (email != null) ...[ + const SizedBox(height: 2), + Text( + email!, + style: OmiType.subhead.copyWith(color: OmiColors.textSecondary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ExcludeSemantics(child: Icon(Icons.chevron_right, size: 20, color: OmiColors.textTertiary)), + ], + ), ), ), - ], + ), ); } } 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..29b99489ad0 --- /dev/null +++ b/app/lib/pages/settings/widgets/settings_plan_section.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; + +import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/upgrade_paywall.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/settings_plan_card_experiment.dart'; +import 'package:omi/ui/ui.dart'; +import 'package:omi/utils/l10n_extensions.dart'; + +/// Plan & Usage on the Settings sheet. Every plan but Free and Plus (and no subscription yet) keeps +/// the plain row. Free and Plus get one of two layouts from the `settings-plan-card` A/B +/// ([SettingsPlanCardExperiment]): control keeps the row with the minutes meter and a full-width +/// Upgrade under it; `card` is a plan card with the plan name large, the meter, then a full-width +/// Upgrade, and tapping the card outside the button opens the Plan & Usage page. +class SettingsPlanSection extends StatefulWidget { + const SettingsPlanSection({super.key, required this.row, required this.onOpen, this.planName, this.arm}); + + /// The plain Plan & Usage row, shown when there is nothing to upgrade to. + final Widget row; + + /// Opens the Plan & Usage page. + final VoidCallback onOpen; + + /// The plan's display name ("Free", the Plus plan's name). + final String? planName; + + /// Forces an arm (tests); null reads the experiment. + final SettingsPlanCardArm? arm; + + @override + State createState() => _SettingsPlanSectionState(); +} + +class _SettingsPlanSectionState extends State { + SettingsPlanCardArm? _arm; + bool _exposed = false; + + @override + void initState() { + super.initState(); + _arm = widget.arm ?? SettingsPlanCardExperiment.cached; + if (widget.arm == null) { + SettingsPlanCardExperiment.resolve().then((arm) { + if (mounted && arm != _arm) setState(() => _arm = arm); + }); + } + } + + /// Counts the reader once the section is drawn for Free or Plus with the arm PostHog assigned. + void _expose(SettingsPlanCardArm arm) { + if (_exposed || widget.arm != null) return; + _exposed = true; + WidgetsBinding.instance.addPostFrameCallback((_) => SettingsPlanCardExperiment.recordExposure(arm)); + } + + @override + Widget build(BuildContext context) { + final usage = context.watch(); + final subscription = usage.subscription; + final plan = subscription?.subscription.plan; + if (subscription == null || !usage.showSubscriptionUI || (plan != PlanType.basic && plan != PlanType.plus)) { + return widget.row; + } + final arm = _arm; + if (arm == null) return widget.row; + _expose(arm); + final upgrade = OmiButton( + key: const ValueKey('settings_plan_upgrade'), + label: plan == PlanType.plus ? context.l10n.upgradeToUnlimited : context.l10n.upgrade, + expand: true, + onPressed: () => openSettingsUpgrade(context), + ); + if (arm == SettingsPlanCardArm.control) { + return _ControlSection(row: widget.row, subscription: subscription, upgrade: upgrade); + } + return _PlanCard(planName: widget.planName, onOpen: widget.onOpen, subscription: subscription, upgrade: upgrade); + } +} + +/// Control: the Plan & Usage row, then the minutes meter and a full-width Upgrade under it. +class _ControlSection extends StatelessWidget { + const _ControlSection({required this.row, required this.subscription, required this.upgrade}); + + final Widget row; + final UserSubscriptionResponse subscription; + final Widget upgrade; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hasLimit = subscription.transcriptionSecondsLimit > 0; + final used = (subscription.transcriptionSecondsUsed / 60).round(); + final limit = (subscription.transcriptionSecondsLimit / 60).round(); + final ratio = hasLimit + ? (subscription.transcriptionSecondsUsed / subscription.transcriptionSecondsLimit).clamp(0.0, 1.0) + : 0.0; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + row, + Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, 0, OmiSpacing.lg, OmiSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + if (hasLimit) ...[ + Text( + l10n.minsUsedThisMonth(NumberFormat.decimalPattern(l10n.localeName).format(used), limit), + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + const SizedBox(height: OmiSpacing.xs), + LinearProgressIndicator( + value: ratio, + backgroundColor: OmiColors.surface3, + valueColor: AlwaysStoppedAnimation(ratio >= .8 ? OmiColors.warning : OmiColors.accent), + minHeight: 4, + borderRadius: OmiRadius.pillAll, + ), + const SizedBox(height: OmiSpacing.md), + ], + upgrade, + ], + ), + ), + ], + ); + } +} + +/// Test arm: a plan card with the plan name large, the minutes meter, then a full-width Upgrade. +class _PlanCard extends StatelessWidget { + const _PlanCard({required this.planName, required this.onOpen, required this.subscription, required this.upgrade}); + + final String? planName; + final VoidCallback onOpen; + final UserSubscriptionResponse subscription; + final Widget upgrade; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hasLimit = subscription.transcriptionSecondsLimit > 0; + final used = (subscription.transcriptionSecondsUsed / 60).round(); + final limit = (subscription.transcriptionSecondsLimit / 60).round(); + final ratio = hasLimit + ? (subscription.transcriptionSecondsUsed / subscription.transcriptionSecondsLimit).clamp(0.0, 1.0) + : 0.0; + return Semantics( + key: const ValueKey('settings_row_planAndUsage'), + button: true, + label: l10n.planAndUsage, + child: InkWell( + onTap: onOpen, + child: Padding( + padding: const EdgeInsets.fromLTRB(OmiSpacing.lg, OmiSpacing.md, OmiSpacing.md, OmiSpacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + ExcludeSemantics( + child: Text( + l10n.planAndUsage, + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + ), + const SizedBox(height: 2), + if (planName != null) Text(planName!, style: OmiType.title2), + ], + ), + ), + ExcludeSemantics(child: Icon(Icons.chevron_right, size: 20, color: OmiColors.textTertiary)), + ], + ), + if (hasLimit) ...[ + const SizedBox(height: OmiSpacing.md), + LinearProgressIndicator( + value: ratio, + backgroundColor: OmiColors.surface3, + valueColor: AlwaysStoppedAnimation(ratio >= .8 ? OmiColors.warning : OmiColors.accent), + minHeight: 6, + borderRadius: OmiRadius.pillAll, + ), + const SizedBox(height: OmiSpacing.xs), + Text( + l10n.minsUsedThisMonth(NumberFormat.decimalPattern(l10n.localeName).format(used), limit), + style: OmiType.footnote.copyWith(color: OmiColors.textSecondary), + ), + ], + const SizedBox(height: OmiSpacing.lg), + upgrade, + ], + ), + ), + ), + ); + } +} + +/// The Settings Upgrade button: the paywall experiment's arm for the reader, else the plans sheet. +Future openSettingsUpgrade(BuildContext context) => openUpgradePaywall(context, source: 'usage_page'); diff --git a/app/lib/pages/settings/widgets/standalone_plans_sheet.dart b/app/lib/pages/settings/widgets/standalone_plans_sheet.dart new file mode 100644 index 00000000000..6a53edd7ac3 --- /dev/null +++ b/app/lib/pages/settings/widgets/standalone_plans_sheet.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +import 'package:omi/pages/settings/widgets/plans_sheet.dart'; +import 'package:omi/ui/ui.dart'; + +/// [PlansSheet] with its own animation controllers, for callers that open the sheet without +/// hosting the Plan & Usage page's animations. +class StandalonePlansSheet extends StatefulWidget { + const StandalonePlansSheet({super.key, this.analyticsSource = 'usage_page'}); + + final String analyticsSource; + + @override + State createState() => _StandalonePlansSheetState(); +} + +class _StandalonePlansSheetState extends State with TickerProviderStateMixin { + late final AnimationController _waveController = + AnimationController(duration: const Duration(milliseconds: 18000), vsync: this)..repeat(); + late final AnimationController _arrowController = + AnimationController(duration: const Duration(milliseconds: 800), vsync: this)..repeat(reverse: true); + late final Animation _arrowAnimation = + Tween(begin: 0, end: 3).animate(CurvedAnimation(parent: _arrowController, curve: Curves.easeInOut)); + + @override + void dispose() { + _waveController.dispose(); + _arrowController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PlansSheet( + waveController: _waveController, + notesController: _waveController, + arrowController: _arrowController, + arrowAnimation: _arrowAnimation, + analyticsSource: widget.analyticsSource, + ); + } +} + +/// Opens the plans sheet over [context]. +Future showStandalonePlansSheet(BuildContext context, {String analyticsSource = 'usage_page'}) async { + final title = await plansSheetTitle(context); + if (!context.mounted) return; + return showOmiSheet( + context: context, + title: title, + padding: EdgeInsets.zero, + builder: (_) => StandalonePlansSheet(analyticsSource: analyticsSource), + ); +} diff --git a/app/lib/pages/settings/widgets/upgrade_paywall.dart b/app/lib/pages/settings/widgets/upgrade_paywall.dart new file mode 100644 index 00000000000..803a4d27bea --- /dev/null +++ b/app/lib/pages/settings/widgets/upgrade_paywall.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +import 'package:provider/provider.dart'; + +import 'package:omi/pages/paywall/paywall_router.dart'; +import 'package:omi/pages/settings/widgets/standalone_plans_sheet.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/paywall_experiment.dart'; +import 'package:omi/utils/platform/platform_manager.dart'; + +/// Shows the upgrade offer in place and logs `Paywall Opened` with [source] (a `PaywallOpenedSource` +/// wire name) once. [trackOpen] is false when the opener already logged it. +/// +/// The paywall experiment ([PaywallRouter]) picks the reader's arm; control, paid readers and any +/// failure get the existing plans sheet over the current screen. [forceNative] skips the router and +/// opens the sheet itself: the web variant's fallback, so a dead page never loops back into itself. +Future openUpgradePaywall( + BuildContext context, { + required String source, + bool trackOpen = true, + bool forceNative = false, + @visibleForTesting PaywallExperimentConfig? experiment, +}) async { + void logOpen() { + if (!trackOpen) return; + trackOpen = false; + final plan = context.read().subscription?.subscription.plan.wireName ?? 'unknown'; + PlatformManager.instance.analytics.paywallOpened(source, currentPlan: plan); + } + + if (!forceNative) { + // The router logs `Paywall Opened` itself when it shows a variant. + if (await PaywallRouter.openVariant(context, source: source, trackOpen: trackOpen, config: experiment)) return; + if (!context.mounted) return; + } + logOpen(); + await showStandalonePlansSheet(context, analyticsSource: source); +} diff --git a/app/lib/services/experiments/settings_plan_card_experiment.dart b/app/lib/services/experiments/settings_plan_card_experiment.dart new file mode 100644 index 00000000000..7aee31fcad5 --- /dev/null +++ b/app/lib/services/experiments/settings_plan_card_experiment.dart @@ -0,0 +1,79 @@ +import 'package:flutter/foundation.dart'; + +import 'package:omi/utils/analytics/analytics_manager.dart'; + +/// The arms of the Settings plan card A/B (`settings-plan-card`). +enum SettingsPlanCardArm { + /// The Plan & Usage row with the minutes meter and a full-width Upgrade under it. + control('control'), + + /// A plan card: the plan name large, the minutes meter, then a full-width Upgrade. + card('card'); + + const SettingsPlanCardArm(this.wire); + + final String wire; + + static SettingsPlanCardArm? fromWire(String? value) { + for (final arm in values) { + if (arm.wire == value) return arm; + } + return null; + } +} + +/// Which plan section Free and Plus readers see on the Settings sheet. +/// +/// Lifecycle: experiment; owner: yinka; review-by: 2026-11-15; default control. +/// Pre-registration: product/experiments/settings-plan-card.md. +/// +/// Fail-closed: no identity, a slow read or an unknown variant all mean control, and such a +/// fallback is never recorded as an exposure, so only readers PostHog actually assigned are counted. +abstract final class SettingsPlanCardExperiment { + static const enabledFlag = 'settings-plan-card'; + + /// Debug builds can force an arm: `--dart-define=OMI_SETTINGS_PLAN_CARD=card`. + static const _debugArm = String.fromEnvironment('OMI_SETTINGS_PLAN_CARD'); + + static SettingsPlanCardArm? _cached; + static bool _cachedFromFlag = false; + static String? _cachedIdentity; + + /// The arm cached for the current identity, if [resolve] already ran; lets the sheet draw the + /// right section on its first frame after the first open. + static SettingsPlanCardArm? get cached => _cachedIdentity == AnalyticsManager().settledDistinctId ? _cached : null; + + static Future resolve({Future Function(String key)? readVariant}) async { + if (kDebugMode && _debugArm.isNotEmpty) { + return SettingsPlanCardArm.fromWire(_debugArm) ?? SettingsPlanCardArm.control; + } + final identity = AnalyticsManager().settledDistinctId; + if (readVariant == null && _cached != null && _cachedIdentity == identity) return _cached!; + SettingsPlanCardArm? arm; + try { + final variant = await (readVariant ?? AnalyticsManager().getFeatureFlagVariant)(enabledFlag) + .timeout(const Duration(seconds: 2)); + arm = SettingsPlanCardArm.fromWire(variant); + } catch (_) { + arm = null; + } + _cachedFromFlag = arm != null; + _cached = arm ?? SettingsPlanCardArm.control; + _cachedIdentity = identity; + return _cached!; + } + + /// Records the exposure PostHog Experiments counts (`$feature_flag_called`, once per identity and + /// arm) at the moment the section is drawn for a Free or Plus reader. Skipped for a fallback arm. + static bool recordExposure(SettingsPlanCardArm arm, {bool Function(String key, String variant)? record}) { + if (!_cachedFromFlag && record == null) return false; + return (record ?? AnalyticsManager().recordFeatureFlagExposure)(enabledFlag, arm.wire); + } + + @visibleForTesting + static void resetForTesting() { + _cached = null; + _cachedFromFlag = false; + _cachedIdentity = null; + } +} diff --git a/app/test/widgets/settings/settings_plan_section_test.dart b/app/test/widgets/settings/settings_plan_section_test.dart new file mode 100644 index 00000000000..088a5ad54f6 --- /dev/null +++ b/app/test/widgets/settings/settings_plan_section_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/l10n/app_localizations.dart'; +import 'package:omi/models/subscription.dart'; +import 'package:omi/pages/settings/widgets/settings_plan_section.dart'; +import 'package:omi/providers/usage_provider.dart'; +import 'package:omi/services/experiments/settings_plan_card_experiment.dart'; + +/// The Settings plan section is a plan card (name, Upgrade, this month's minutes) for Free and Plus, +/// and leaves the plain Plan & Usage row for every other plan. +class _Usage extends ChangeNotifier implements UsageProvider { + _Usage(this.subscription, {this.showSubscriptionUI = true}); + + @override + final UserSubscriptionResponse? subscription; + + @override + final bool showSubscriptionUI; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +UserSubscriptionResponse _response(PlanType plan, {int usedSeconds = 0, int limitSeconds = 0}) => + UserSubscriptionResponse( + subscription: Subscription(plan: plan, status: SubscriptionStatus.active), + transcriptionSecondsUsed: usedSeconds, + transcriptionSecondsLimit: limitSeconds, + wordsTranscribedUsed: 0, + wordsTranscribedLimit: 0, + insightsGainedUsed: 0, + insightsGainedLimit: 0, + ); + +void _noop() {} + +void main() { + final en = lookupAppLocalizations(const Locale('en')); + const row = Text('Plan row', key: ValueKey('plan_row')); + + Future pump(WidgetTester tester, UsageProvider usage, + {SettingsPlanCardArm arm = SettingsPlanCardArm.card}) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: usage, + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold(body: SettingsPlanSection(row: row, planName: 'Free', onOpen: _noop, arm: arm)), + ), + ), + ); + } + + testWidgets('Free is a plan card: name, minutes used and Upgrade, in place of the row', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic, usedSeconds: 240 * 60, limitSeconds: 300 * 60))); + + expect(find.byKey(const ValueKey('plan_row')), findsNothing); + expect(find.byKey(const ValueKey('settings_row_planAndUsage')), findsOneWidget); + expect(find.text('Free'), findsOneWidget); + expect(find.text(en.minsUsedThisMonth('240', 300)), findsOneWidget); + expect(find.byType(LinearProgressIndicator), findsOneWidget); + expect(find.text(en.upgrade), findsOneWidget); + }); + + testWidgets('tapping the card outside Upgrade opens Plan & Usage', (tester) async { + var opened = 0; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: _Usage(_response(PlanType.basic, usedSeconds: 60, limitSeconds: 300 * 60)), + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('en'), + home: Scaffold( + body: SettingsPlanSection( + row: row, + planName: 'Free', + onOpen: () => opened++, + arm: SettingsPlanCardArm.card, + ), + ), + ), + ), + ); + await tester.tap(find.text('Free')); + expect(opened, 1); + }); + + testWidgets('control keeps the row with minutes and a full-width Upgrade under it', (tester) async { + await pump( + tester, + _Usage(_response(PlanType.basic, usedSeconds: 240 * 60, limitSeconds: 300 * 60)), + arm: SettingsPlanCardArm.control, + ); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.text('Free'), findsNothing); + expect(find.text(en.minsUsedThisMonth('240', 300)), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsOneWidget); + }); + + group('experiment', () { + tearDown(SettingsPlanCardExperiment.resetForTesting); + + test('an unknown or missing variant falls back to control and is not counted', () async { + SettingsPlanCardExperiment.resetForTesting(); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => null), SettingsPlanCardArm.control); + expect(SettingsPlanCardExperiment.recordExposure(SettingsPlanCardArm.control), isFalse); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => 'other'), SettingsPlanCardArm.control); + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) => Future.error('offline')), + SettingsPlanCardArm.control); + }); + + test('an assigned arm is counted under the settings-plan-card flag', () async { + expect(await SettingsPlanCardExperiment.resolve(readVariant: (_) async => 'card'), SettingsPlanCardArm.card); + final recorded = []; + SettingsPlanCardExperiment.recordExposure( + SettingsPlanCardArm.card, + record: (key, variant) { + recorded.add('$key=$variant'); + return true; + }, + ); + expect(recorded, ['settings-plan-card=card']); + }); + }); + + testWidgets('Plus offers Upgrade to Unlimited', (tester) async { + await pump(tester, _Usage(_response(PlanType.plus, usedSeconds: 60, limitSeconds: 1500 * 60))); + + expect(find.text(en.upgradeToUnlimited), findsOneWidget); + expect(find.text(en.minsUsedThisMonth('1', 1500)), findsOneWidget); + }); + + testWidgets('Free with no transcription limit shows Upgrade without a meter', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic))); + + expect(find.byType(LinearProgressIndicator), findsNothing); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsOneWidget); + }); + + for (final plan in [PlanType.unlimitedV2, PlanType.unlimited, PlanType.operator, PlanType.architect]) { + testWidgets('${plan.name} keeps the plain row', (tester) async { + await pump(tester, _Usage(_response(plan))); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); + } + + testWidgets('hidden subscription UI keeps the plain row', (tester) async { + await pump(tester, _Usage(_response(PlanType.basic, limitSeconds: 18000), showSubscriptionUI: false)); + + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); + + testWidgets('no subscription yet keeps the plain row', (tester) async { + await pump(tester, _Usage(null)); + + expect(find.byKey(const ValueKey('plan_row')), findsOneWidget); + expect(find.byKey(const ValueKey('settings_plan_upgrade')), findsNothing); + }); +} diff --git a/app/test/widgets/settings/settings_search_header_stability_test.dart b/app/test/widgets/settings/settings_search_header_stability_test.dart index 1ed62ab23b8..77465f6b503 100644 --- a/app/test/widgets/settings/settings_search_header_stability_test.dart +++ b/app/test/widgets/settings/settings_search_header_stability_test.dart @@ -8,6 +8,7 @@ import 'package:omi/backend/preferences.dart'; import 'package:omi/l10n/app_localizations.dart'; import 'package:omi/pages/settings/settings_drawer.dart'; import 'package:omi/providers/app_provider.dart'; +import 'package:omi/providers/device_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/user_provider.dart'; import 'package:omi/providers/usage_provider.dart'; @@ -18,6 +19,15 @@ import 'package:omi/utils/platform/platform_manager.dart'; // and the field sits 4 pt off the cards. Header height must remain constant (48 pt) // and search field must align with the settings cards (padding left OmiSpacing.lg = 20 pt). +/// No device connected: the Device row reads Disconnected. +class _Device extends ChangeNotifier implements DeviceProvider { + @override + bool get isConnected => false; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + Widget _buildHarness() { return MultiProvider( providers: [ @@ -25,6 +35,7 @@ Widget _buildHarness() { ChangeNotifierProvider.value(value: HomeProvider()), ChangeNotifierProvider.value(value: UserProvider()), ChangeNotifierProvider.value(value: UsageProvider()), + ChangeNotifierProvider.value(value: _Device()), ], child: const MaterialApp( localizationsDelegates: [ diff --git a/app/test/widgets/settings/settings_sheet_appearance_test.dart b/app/test/widgets/settings/settings_sheet_appearance_test.dart index 69806bb586b..80c7adcd1a1 100644 --- a/app/test/widgets/settings/settings_sheet_appearance_test.dart +++ b/app/test/widgets/settings/settings_sheet_appearance_test.dart @@ -24,6 +24,9 @@ class _Device extends ChangeNotifier implements DeviceProvider { @override bool get isConnected => true; + @override + int get batteryLevel => -1; + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } diff --git a/app/test/widgets/settings/settings_structure_test.dart b/app/test/widgets/settings/settings_structure_test.dart index 2f29eb1669a..4b1fca75747 100644 --- a/app/test/widgets/settings/settings_structure_test.dart +++ b/app/test/widgets/settings/settings_structure_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -30,6 +31,9 @@ class _Device extends ChangeNotifier implements DeviceProvider { @override bool get isConnected => true; + @override + int get batteryLevel => 82; + @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } @@ -141,45 +145,49 @@ void main() { } testWidgets( - 'the sheet is Account, Plan, Referral, the groups, Connect an Agent, Memories, Goals and Feedback, in order, keyed', + 'the sheet is Account (name and email), Plan, the most-opened groups, Voice, Integrations, Connect an Agent, Memories, Goals, Help, Referral and Feedback, in order, keyed', ( tester, ) async { await pumpSheet(tester); final rows = _rowsOnScreen(tester); expect(rows.map(_keyOf).toList(), [ - 'settings_account', 'settings_row_planAndUsage', - 'settings_row_referral', 'settings_group_device', 'settings_group_recording', - 'settings_group_voice', 'settings_group_notifications', + 'settings_group_privacy', + 'settings_group_voice', 'settings_group_integrations', 'settings_row_connect_agent', - 'settings_group_privacy', 'settings_row_memories', 'settings_row_goals', 'settings_group_help', + 'settings_row_referral', 'settings_row_feedback', // where Intercom is supported (the host test is) 'settings_group_developer', ]); - // Account shows who is signed in. - expect(rows.first.title, 'Ada'); - expect(rows.first.subtitle, 'ada@example.com'); - expect(rows.skip(1).map((r) => r.title).toList(), [ + // Account, above the rows, shows who is signed in: the name and the email, no picture. + final account = find.byKey(const ValueKey('settings_account')); + expect(account, findsOneWidget); + expect(find.descendant(of: account, matching: find.text('Ada')), findsOneWidget); + expect(find.descendant(of: account, matching: find.text('ada@example.com')), findsOneWidget); + expect(find.descendant(of: account, matching: find.byType(FaIcon)), findsNothing); + // Device shows the connected device's battery without opening the group. + expect(rows[1].value, '82%'); + expect(rows.map((r) => r.title).toList(), [ en.planAndUsage, - en.referralProgram, en.device, en.recordingAndTranscription, - en.assistantVoiceSettingsTitle, en.notificationsAndDisplay, + en.dataAndPrivacy, + en.assistantVoiceSettingsTitle, en.integrations, en.connectAnAgent, - en.dataAndPrivacy, en.memories, en.goals, en.helpAndAbout, + en.referralProgram, en.feedbackBug, en.developerSettings, ]); @@ -205,6 +213,8 @@ void main() { } record(0); + // Account is a header above the rows rather than a row. + if (find.byKey(const ValueKey('settings_account')).evaluate().isNotEmpty) keyDepth['settings_account'] = 0; // Depth 1: every page of rows one tap below the sheet. final pageTitles = >{}; diff --git a/backend/docs/feature-flag-registry.md b/backend/docs/feature-flag-registry.md index e424b446202..77c540eec7c 100644 --- a/backend/docs/feature-flag-registry.md +++ b/backend/docs/feature-flag-registry.md @@ -111,6 +111,7 @@ entries are exempt: they are queued for removal, not running. | `exp-002-desktop-identity-v1` | unowned | [backend/docs/experiments/EXP-002-desktop-identity-memory-v1.md](../../backend/docs/experiments/EXP-002-desktop-identity-memory-v1.md) | 2026-10-26 | | `onboarding-variant` | nik | [product/experiments/onboarding-variant.md](../../product/experiments/onboarding-variant.md) | 2026-11-13 | | `paywall-variant` | nik | [product/experiments/paywall-variant.md](../../product/experiments/paywall-variant.md) | 2026-11-12 | +| `settings-plan-card` | yinka | [product/experiments/settings-plan-card.md](../../product/experiments/settings-plan-card.md) | 2026-11-15 | ## Overdue for a decision @@ -155,6 +156,7 @@ and an explicit empty literal renders as `''`. | `exp-002-desktop-identity-v1` | EXP-002 memory_v1 desktop identity arm enrollment | backend, macos | posthog | closed | — | — | — | expected (enable) | pending | 2026-10-26 | unowned | | `onboarding-variant` | Mobile first-run onboarding A/B/C after sign-in and consent: control (native steps unchanged) / reid (device-first onboarding rendered from a web page, OTA; payload names it) / yinka (onboarding ending in a first conversation, PR #21445, native). Client-evaluated multivariate flag; an unread flag, an unknown variant or a reid payload without an https URL is control; the page never owns completion | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-13 | nik | | `paywall-variant` | Mobile paywall A/B test: control (current PlansSheet) / outline (PR #21412 soft paywall, native) / web (webview paywall whose page is the variant). Client-evaluated multivariate flag; control collapses to the shipped sheet, paid readers and App Review builds never see a variant | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-12 | nik | +| `settings-plan-card` | Mobile Settings plan section A/B for Free and Plus readers: control (Plan & Usage row with the minutes meter and a full-width Upgrade under it) / card (plan card: plan name large, minutes meter, full-width Upgrade; tapping it opens Plan & Usage). Client-evaluated multivariate flag; no identity, an unread flag or an unknown variant is control and is not counted | mobile | posthog | closed | — | — | — | expected (exposure) | pending | 2026-11-15 | yinka | ### rollout diff --git a/config/feature-flags.yaml b/config/feature-flags.yaml index 6913a1e7912..5c8ccdbe8e7 100644 --- a/config/feature-flags.yaml +++ b/config/feature-flags.yaml @@ -30,6 +30,7 @@ flags: - {"key":"REPEAT_WORK_LLM_VERSION","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Explicit model/prompt deployment version for exact-input reuse; bump with gateway model or schema config trains","fail":"closed","owner":"dazheng","created":"2026-10-16","decision":"keep"} - {"key":"REPEAT_WORK_REVISION_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Reuse existing sync outbox with 60min ceiling and exact successful snapshot-based notes/L1 dedupe; content-free call/final joins","fail":"closed","owner":"dazheng","created":"2026-10-16","decision":"pending","review_by":"2026-11-15"} - {"key":"SYNC_POLL_BACKOFF_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Return queued 60s, active/open 20s and terminal stop poll hints without extra Firestore reads","fail":"closed","owner":"dazheng","created":"2026-10-16","decision":"pending","review_by":"2026-11-15"} + - {"key":"settings-plan-card","prereg":"product/experiments/settings-plan-card.md","aliases":[],"kind":"posthog","lifecycle":"experiment","surfaces":["mobile"],"summary":"Mobile Settings plan section A/B for Free and Plus readers: control (Plan & Usage row with the minutes meter and a full-width Upgrade under it) / card (plan card: plan name large, minutes meter, full-width Upgrade; tapping it opens Plan & Usage). Client-evaluated multivariate flag; no identity, an unread flag or an unknown variant is control and is not counted","fail":"closed","owner":"yinka","created":"2026-10-15","review_by":"2026-11-15","decision":"pending","posthog":{"row":"expected","role":"exposure"},"notes":"PostHog flag 963589, experiment 486057 (draft; launch once the build is in the stores). Exposure is $feature_flag_called when the section is drawn for a Free or Plus reader with a PostHog-assigned arm. Debug builds: --dart-define=OMI_SETTINGS_PLAN_CARD=control|card."} - {"key":"SYNC_REPROCESS_COALESCE_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Coalesce sync append enrichment after a durable quiet window; code/dev off, Train 1 prod sync hosts on","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"pending","notes":"Train 1 only after emulator and local HTTP qualification. Off restores eager appends; existing outbox obligations still drain.","review_by":"2026-11-14"} - {"key":"SYNC_REPROCESS_SETTLE_SECONDS","aliases":[],"kind":"env","lifecycle":"config_switch","surfaces":["backend"],"summary":"Sync append enrichment quiet window in seconds; default 600, bounded 1 to 86400","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"keep","notes":"Only used when sync reprocess coalescing is enabled. Repeated marks extend one outstanding obligation."} - {"key":"CONVERSATION_LLM_INPUT_DEDUPE_ENABLED","aliases":[],"kind":"env","lifecycle":"rollout","surfaces":["backend"],"summary":"Reuse notes and L1 for exact successful effective inputs; code/dev off, Train 1 prod sync hosts on","fail":"closed","owner":"dazheng","created":"2026-10-15","decision":"pending","notes":"Includes language, capture dates, rendered prompt context, feedback and prompt/schema implementation. Off restores feature invocations without deleting successful hashes.","review_by":"2026-11-14"} diff --git a/product/experiments/settings-plan-card.md b/product/experiments/settings-plan-card.md new file mode 100644 index 00000000000..0b5aafd793d --- /dev/null +++ b/product/experiments/settings-plan-card.md @@ -0,0 +1,40 @@ +# Pre-registration: Settings plan card A/B (`settings-plan-card`) + +**Question.** Does a plan card on the Settings sheet (the plan name large, the minutes meter and a +full-width Upgrade) raise free→paid conversion compared with the Plan & Usage row that has the +meter and Upgrade under it? Settings is opened by about 40% of active readers and most Settings +upgrades start there. + +**Arms** (PostHog multivariate flag `settings-plan-card`, id 963589, experiment 486057, 50/50, split by +distinct id; draft until this build is in the stores): +- `control`: the Plan & Usage row, then the minutes meter and a full-width Upgrade. +- `card`: a plan card with a small "Plan & Usage" label, the plan name in large type, the minutes + meter and line, then a full-width Upgrade; tapping the card outside Upgrade opens Plan & Usage. +Both arms open the same paywall (`openUpgradePaywall`, source `usage_page`), so `paywall-variant` +picks the design in either arm. + +**Unit.** A Free or Plus reader who opens Settings. Exposure = `$feature_flag_called` with +`$feature_flag = settings-plan-card`, once per identity and arm, when the section is drawn for a +Free or Plus reader. Readers on other plans, readers with subscription UI hidden, and readers whose +arm is a fallback (no identity, unread flag, unknown variant) are never counted. + +**Primary metric.** `Upgrade Succeeded` per exposed reader (funnel, 7-day window). + +**Secondary / guardrails.** `Paywall Variant Shown` with `source = usage_page` per exposed reader +(Upgrade taps); `Upgrade Plan Selected` per exposed reader; Plan & Usage page opens per exposed +reader; `Product Error` per reader. + +**Sample size.** About 6,800 readers open Settings in 30 days and about 8% of them upgrade in +that window (PostHog, 2026-10-15); most are Free. That is roughly 3,000 exposed readers per arm per +month, enough to detect a change of about 25% relative (8% to 10%) at 95% confidence and 80% power, +not a 10% one. + +**Decision rule.** Run 4 weeks from launch (at least 3,000 exposed readers per arm), then read the +primary metric at PostHog's default confidence. Ship `card` only if it wins on the primary metric +without a worse guardrail; a flat result keeps control (the smaller change). Delete the losing +layout and this flag. + +**Kill switch.** Set the split to control 100% (or deactivate the flag); every build shows the +control layout without a release. + +**Owner.** yinka. Created 2026-10-15. Review by 2026-11-15.