Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
63b494a
feat(retention): add UID-held-out yearly switch placements (SCA-1808)
Git-on-my-level Nov 12, 2026
f44172a
test(retention): verify serving query and approval render contracts
Git-on-my-level Nov 12, 2026
84bf580
chore(retention): align kill-switch registry with default-on parser
Git-on-my-level Nov 12, 2026
dd41b13
SCA-1808: Keep unverifiable retention receipts out of live acceptance
Git-on-my-level Nov 13, 2026
9efe186
Merge main into SCA-1808 and repair retention verification checks
Git-on-my-level Nov 15, 2026
c48873b
SCA-1808: Bind refreshed render evidence to repaired source
Git-on-my-level Nov 15, 2026
32d7201
SCA-1808: Conform refreshed packet to UI evidence contract
Git-on-my-level Nov 15, 2026
8bb80d6
SCA-1808: Short-circuit retention admission reads
Git-on-my-level Nov 15, 2026
e178836
Merge main and re-arm Home retention admission on later presentation
Git-on-my-level Nov 15, 2026
8a54fdc
Use constant non-admission fixtures in Home regressions
Git-on-my-level Nov 15, 2026
c7d6cd6
Merge current main into SCA-1808 retention branch
Git-on-my-level Nov 15, 2026
452a9a8
Merge remote-tracking branch 'origin/main' into agent/m1-studio-codex…
Git-on-my-level Nov 15, 2026
2741779
fix(retention): close busy race and move UID erase to database
Git-on-my-level Nov 15, 2026
9400873
test(retention): bind Firestore witness to module-level imports
Git-on-my-level Nov 15, 2026
7c510ce
fix(SCA-1808): merge current main and upstream CI cleanup repair
Git-on-my-level Nov 15, 2026
dd66f42
fix(SCA-1808): refresh generated client method count after integration
Git-on-my-level Nov 15, 2026
26bdd57
fix(SCA-1808): refresh TypeScript method counts after integration
Git-on-my-level Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
31 changes: 31 additions & 0 deletions .agent-artifacts/ui-evidence/yearly-retention/copy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"approval": "PENDING; no authorization or activation",
"locale": "en",
"strings": {
"yearlySwitchTitle": "Switch to Yearly",
"yearlySwitchHomeTitle": "Save {amount} a year with yearly",
"yearlySwitchHomeSubtitle": "Same plan, billed once a year",
"yearlySwitchPlanValue": "{plan}, {price} a year",
"yearlySwitchDueToday": "Due today",
"yearlySwitchRenews": "Renews",
"yearlySwitchCreditNote": "Includes credit for the rest of this month.",
"yearlySwitchDeclined": "Payment declined. You're still on monthly.",
"yearlySwitchFailed": "Couldn't switch. You're still on monthly.",
"yearlySwitchPreviewFailed": "Couldn't load your price.",
"yearlySwitchUnavailable": "Yearly billing isn't available for your plan right now.",
"yearlySwitchDoneTitle": "You're on yearly",
"yearlySwitchDoneBody": "{amount} charged today. Renews {date}.",
"cancelSubscription": "Cancel Subscription",
"retentionOfferBody": "Stay and pay {price} a month for the next {months} months.",
"retentionOfferAccept": "Stay for {percent}% off",
"cancel": "Cancel"
},
"fixture_amounts": {
"monthly_minor": 2999,
"yearly_minor": 26991,
"savings_minor": 8997,
"due_today_minor": 24492,
"currency": "USD"
},
"note": "Existing list-price yearly billing, not a new discount. Rendered amounts are synthetic, never verified live prices."
}
54 changes: 54 additions & 0 deletions .agent-artifacts/ui-evidence/yearly-retention/evidence.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"version": 1,
"images": [
{
"file": "001-mobile-ios-cancel-before.png",
"platform": "mobile-ios",
"description": "Historical production cancellation source at ac02feb05f0d6389f473e08a25980397b1348b0f; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "002-mobile-ios-cancel-control.png",
"platform": "mobile-ios",
"description": "Current cancellation control/no yearly offer; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "003-mobile-ios-cancel-after.png",
"platform": "mobile-ios",
"description": "Production cancellation, too_expensive treatment; yearly before discount; Cancel pinned; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "004-mobile-ios-engaged-before.png",
"platform": "mobile-ios",
"description": "Minimal Home host before/flag-off: no new placement; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "005-mobile-ios-engaged-after.png",
"platform": "mobile-ios",
"description": "Production engaged placement in minimal Home host, not full Home shell; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "006-mobile-ios-price-preview.png",
"platform": "mobile-ios",
"description": "Production existing yearly preview, exact synthetic charge and annual total; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
},
{
"file": "007-mobile-ios-payment-declined.png",
"platform": "mobile-ios",
"description": "Production switch decline, monthly retained; dismiss sheet to Cancel subscription; synthetic widget raster, not a device screenshot",
"captured_by": "agent",
"source": "visual-audit"
}
]
}
60 changes: 60 additions & 0 deletions .agent-artifacts/ui-evidence/yearly-retention/render-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{
"viewport_logical": [
430,
932
],
"pixel_ratio": 2,
"theme": "light",
"locale": "en",
"synthetic": true,
"approval": "PENDING",
"historical_source": "ac02feb05f0d6389f473e08a25980397b1348b0f",
"renders": [
{
"file": "001-mobile-ios-cancel-before.png",
"sha256": "18639044ae7e31c48f7b32b6e6785629e2c0343528946e2bfbdf98523843d5dc",
"role": "Historical production cancellation source at ac02feb05f0d6389f473e08a25980397b1348b0f"
},
{
"file": "002-mobile-ios-cancel-control.png",
"sha256": "18639044ae7e31c48f7b32b6e6785629e2c0343528946e2bfbdf98523843d5dc",
"role": "Current cancellation control/no yearly offer"
},
{
"file": "003-mobile-ios-cancel-after.png",
"sha256": "d87a81584b757d67c6e656853f6f34726843feb023419d0e39e3aa87190d4539",
"role": "Production cancellation, too_expensive treatment; yearly before discount; Cancel pinned"
},
{
"file": "004-mobile-ios-engaged-before.png",
"sha256": "c8a533f4e69db77bad22161f65d451011ab375083d1caa8dc28e67aaa7febe6d",
"role": "Minimal Home host before/flag-off: no new placement"
},
{
"file": "005-mobile-ios-engaged-after.png",
"sha256": "d4c3d860cd9a7d1562c6d7cb892bf3211f9f618fe770bcb00c30e69503e24154",
"role": "Production engaged placement in minimal Home host, not full Home shell"
},
{
"file": "006-mobile-ios-price-preview.png",
"sha256": "023cfe9927cbf884783bf71f86598eba22faa1180ed4664db4b368e9d149de57",
"role": "Production existing yearly preview, exact synthetic charge and annual total"
},
{
"file": "007-mobile-ios-payment-declined.png",
"sha256": "5980c2d1eb80abe6c5b44dc8db88a3130a54ad895e88e8a61e814be4e83f189a",
"role": "Production switch decline, monthly retained; dismiss sheet to Cancel subscription"
}
],
"limitations": [
"Widget rasters, not device/system-safe-area or full Home-shell acceptance.",
"No task-owned agent-flutter debug app was available; device/debugger verification not claimed.",
"No actual Stripe price, account, deployment, approval, activation or lift is represented."
],
"source_files_sha256": {
"app/lib/pages/settings/widgets/cancel_subscription_sheet.dart": "a049f3a5bda156086529967dc03c9ba6b437f0633b2d43328a4a8692aa00927f",
"app/lib/pages/settings/widgets/yearly_retention_placement.dart": "76a9256d3ac1a022c705d751aea568b1e78b3f6c8e464da618cbe8460f6133dc",
"app/lib/pages/settings/widgets/yearly_switch.dart": "a6490fdd2c338edaef0667ddfa84dc17a26b03a3c6d93463664e35eec3c9e4e0",
"app/test/widgets/yearly_retention_test.dart": "9a3edebfbccf1755e2168159910b50326d3243f8926e91aa3ca1764a29ca438c"
}
}
1 change: 1 addition & 0 deletions app/changelog/unreleased/20261112-yearly-retention.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"change":"Prepare the existing-price yearly switch before the cancellation discount and once for engaged monthly subscribers, behind approval, rollout, and stable holdout gates."}
23 changes: 23 additions & 0 deletions app/lib/backend/http/api/yearly_switch_retention.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Owns the optional yearly-retention admission/config transport.
// Preserve typed HTTP failures; only UI callers decide to hide an optional offer.
import 'dart:convert';

import 'package:omi/backend/http/api_result.dart';
import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire;
import 'package:omi/env/env.dart';

Future<ApiResult<wire.GeneratedYearlySwitchResponse>> claimYearlyRetention(String placement, {ApiSend? send}) {
if (!const {'cancel', 'engaged'}.contains(placement)) throw ArgumentError.value(placement, 'placement');
return executeApi(
request:
ApiRequest(url: '${Env.apiBaseUrl}v1/payments/yearly-switch-retention?placement=$placement', method: 'POST'),
send: send,
decode: (body) => wire.GeneratedYearlySwitchResponse.fromJson(jsonDecode(body) as Map<String, dynamic>),
);
}

Future<ApiResult<bool>> yearlyRetentionStatus({ApiSend? send}) => executeApi(
request: ApiRequest(url: '${Env.apiBaseUrl}v1/payments/yearly-switch-retention', method: 'GET'),
send: send,
decode: (body) => wire.GeneratedYearlySwitchResponse.fromJson(jsonDecode(body) as Map<String, dynamic>).eligible,
);
3 changes: 3 additions & 0 deletions app/lib/pages/home/page.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:omi/pages/settings/widgets/yearly_retention_placement.dart';

Check warning on line 1 in app/lib/pages/home/page.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/pages/home/page.dart is 1172 lines; consider splitting files over 800 lines.
import 'package:omi/pages/settings/widgets/yearly_switch.dart';
// The home page: capture start/stop entry point, permission gating,
// foreground-service lifecycle, and on-device transcription wiring.
//
Expand Down Expand Up @@ -819,7 +821,7 @@
}

@override
Widget build(BuildContext context) {

Check warning on line 824 in app/lib/pages/home/page.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

Widget build(BuildContext context) is 152 lines; consider extracting focused helpers over 150 lines.
final content = MyUpgradeAlert(
upgrader: _upgrader,
dialogStyle: Platform.isIOS ? UpgradeDialogStyle.cupertino : UpgradeDialogStyle.material,
Expand Down Expand Up @@ -916,6 +918,7 @@
);
},
),
if (onHome) const YearlyRetentionPlacement(surface: YearlySwitchSurface.engaged),
// Home shows an active call in its capture row; Tasks gets the slim bar.
if (!onHome) const ActiveCallTopBar(),
Expanded(
Expand Down
55 changes: 44 additions & 11 deletions app/lib/pages/settings/widgets/cancel_subscription_sheet.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import 'package:provider/provider.dart';
import 'package:omi/backend/http/api/payment.dart' as payment_api;
import 'package:omi/backend/schema/gen/payments_wire.g.dart' as wire;
import 'package:omi/pages/settings/widgets/leave_flow_widgets.dart';
import 'package:omi/pages/settings/widgets/yearly_retention_placement.dart';
import 'package:omi/pages/settings/widgets/yearly_switch.dart';
import 'package:omi/utils/analytics/registry/events.g.dart';
import 'package:omi/utils/analytics/registry/typed_events.dart';
import 'package:omi/providers/usage_provider.dart';
import 'package:omi/ui/ui.dart';
import 'package:omi/utils/l10n_extensions.dart';
Expand All @@ -20,11 +24,17 @@ const _stepCount = 3;
/// step is its own route, so the iOS edge swipe and system back step back one step. This widget
/// is step 1 and owns the flow; [show] resolves `true` when the subscription was cancelled.
class CancelSubscriptionFlow extends StatefulWidget {
const CancelSubscriptionFlow({super.key, this.loadOffer});
const CancelSubscriptionFlow(
{super.key, this.loadOffer, this.yearlyClaim, this.yearlyEnabled, this.yearlyPreview, this.yearlyApply});

/// Hermetic preview/test seam; production reads the retention endpoint.
final Future<wire.GeneratedRetentionOfferResponse?> Function()? loadOffer;

final Future<wire.GeneratedYearlySwitchResponse?> Function(String)? yearlyClaim;
final Future<bool> Function()? yearlyEnabled;
final YearlySwitchLoader? yearlyPreview;
final YearlySwitchApplier? yearlyApply;

static Future<bool?> show(BuildContext context) {
return Navigator.of(context).push<bool>(omiPageRoute(builder: (_) => const CancelSubscriptionFlow()));
}
Expand All @@ -35,7 +45,11 @@ class CancelSubscriptionFlow extends StatefulWidget {

/// What the reader has entered so far, shared by the three steps.
class _CancelFlow {
_CancelFlow({this.loadOffer});
_CancelFlow({this.loadOffer, this.yearlyClaim, this.yearlyEnabled, this.yearlyPreview, this.yearlyApply});
final Future<wire.GeneratedYearlySwitchResponse?> Function(String)? yearlyClaim;
final Future<bool> Function()? yearlyEnabled;
final YearlySwitchLoader? yearlyPreview;
final YearlySwitchApplier? yearlyApply;
final Future<wire.GeneratedRetentionOfferResponse?> Function()? loadOffer;
final exit = LeaveFlowExit();
final details = TextEditingController();
Expand All @@ -45,7 +59,12 @@ class _CancelFlow {
}

class _CancelSubscriptionFlowState extends State<CancelSubscriptionFlow> {
late final _flow = _CancelFlow(loadOffer: widget.loadOffer);
late final _flow = _CancelFlow(
loadOffer: widget.loadOffer,
yearlyClaim: widget.yearlyClaim,
yearlyEnabled: widget.yearlyEnabled,
yearlyPreview: widget.yearlyPreview,
yearlyApply: widget.yearlyApply);

static const _reasons = [
_Reason('too_expensive', FontAwesomeIcons.wallet),
Expand Down Expand Up @@ -201,8 +220,10 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> {
final offer = await (_flow.loadOffer ?? payment_api.getRetentionOffer)();
if (!mounted || offer == null || !offer.eligible || offer.discountedAmount == null) return;
setState(() => _offer = offer);
PlatformManager.instance.analytics
.track('Retention Offer Shown', properties: {'reason': _flow.reason, 'percent_off': offer.percentOff});
const TypedEvents().emit(RetentionOfferShown(
reason: RetentionOfferShownReason.values
.firstWhere((value) => value.wireName == _flow.reason, orElse: () => RetentionOfferShownReason.other),
percentOff: offer.percentOff));
}

String _monthlyPrice(wire.GeneratedRetentionOfferResponse offer) =>
Expand All @@ -212,8 +233,10 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> {
final offer = _offer;
if (offer == null || _busy) return;
setState(() => _isAcceptingOffer = true);
PlatformManager.instance.analytics
.track('Retention Offer Accepted', properties: {'reason': _flow.reason, 'percent_off': offer.percentOff});
const TypedEvents().emit(RetentionOfferAccepted(
reason: RetentionOfferAcceptedReason.values
.firstWhere((value) => value.wireName == _flow.reason, orElse: () => RetentionOfferAcceptedReason.other),
percentOff: offer.percentOff));
final ok = await payment_api.acceptRetentionOffer();
if (!mounted) return;
if (ok) {
Expand Down Expand Up @@ -277,8 +300,18 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> {
title: context.l10n.justAMoment,
subtitle: context.l10n.cancelConsequencesSubtitle,
canPop: !_busy,
body: Column(
body: ListView(
children: [
YearlyRetentionPlacement(
surface: YearlySwitchSurface.cancel,
emphasized: _flow.reason == 'too_expensive',
busy: _busy,
claim: _flow.yearlyClaim,
readEnabled: _flow.yearlyEnabled,
loadPreview: _flow.yearlyPreview,
apply: _flow.yearlyApply,
onSwitched: () => _flow.exit.close(context, false),
),
if (offer != null) ...[
LeaveFlowNotice(
icon: FontAwesomeIcons.tag,
Expand All @@ -293,9 +326,9 @@ class _CancelConfirmStepState extends State<_CancelConfirmStep> {
color: OmiColors.warning,
),
const SizedBox(height: OmiSpacing.md),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.xl),
Padding(
padding: const EdgeInsets.symmetric(horizontal: OmiSpacing.xl),
child: Column(
children: [
LeaveFlowConsequenceRow(icon: FontAwesomeIcons.infinity, text: context.l10n.cancelConsequenceNoAccess),
LeaveFlowConsequenceRow(icon: FontAwesomeIcons.bolt, text: context.l10n.cancelConsequenceBattery),
Expand Down
Loading
Loading