Skip to content

Commit 662e751

Browse files
authored
Add free, unlimited subscription plans (BasedHardware#2734)
* Add free, unlimited subscription plans * Simplify customer subscription event * Use basic term instead of free * Experiment with plan on insights, transcribing, memories * feat(subscription): improve subscription logic, UI handling, and plan limits - Fetch subscription on login to correctly hide UI for reviewers - Dynamically determine subscription limits and features - Show Unlimited for zero limits and hide usage progress bar - Centralize basic plan limits with safer defaults - Refine caching, UI visibility, and general cleanup * Improve experince on out of credits * Add users reports * Add env vars relates to subscriptions to deployment charts
1 parent cbe52e0 commit 662e751

26 files changed

Lines changed: 2051 additions & 256 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import 'dart:convert';
2+
import 'package:flutter/material.dart';
3+
import 'package:omi/backend/http/shared.dart';
4+
import 'package:omi/env/env.dart';
5+
6+
Future<Map<String, String>?> createCheckoutSession({required String priceId}) async {
7+
var response = await makeApiCall(
8+
url: '${Env.apiBaseUrl}v1/payments/checkout-session',
9+
headers: {},
10+
method: 'POST',
11+
body: jsonEncode({'price_id': priceId}),
12+
);
13+
if (response != null && response.statusCode == 200) {
14+
var jsonResponse = jsonDecode(response.body);
15+
debugPrint('createCheckoutSession response: ${response.body}');
16+
return {
17+
'url': jsonResponse['url'] as String,
18+
'session_id': jsonResponse['session_id'] as String,
19+
};
20+
}
21+
return null;
22+
}
23+
24+
Future<bool> cancelSubscription() async {
25+
var response = await makeApiCall(
26+
url: '${Env.apiBaseUrl}v1/payments/subscription',
27+
headers: {},
28+
method: 'DELETE',
29+
body: '',
30+
);
31+
if (response != null && response.statusCode == 200) {
32+
return true;
33+
}
34+
return false;
35+
}

app/lib/backend/http/api/users.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:omi/backend/http/shared.dart';
66
import 'package:omi/backend/schema/geolocation.dart';
77
import 'package:omi/backend/schema/person.dart';
88
import 'package:omi/env/env.dart';
9+
import 'package:omi/models/subscription.dart';
910
import 'package:omi/models/user_usage.dart';
1011
import 'package:omi/utils/platform/platform_manager.dart';
1112

@@ -339,3 +340,18 @@ Future<UserUsageResponse?> getUserUsage({required String period}) async {
339340
}
340341
return null;
341342
}
343+
344+
Future<UserSubscriptionResponse?> getUserSubscription() async {
345+
var response = await makeApiCall(
346+
url: '${Env.apiBaseUrl}v1/users/me/subscription',
347+
headers: {},
348+
method: 'GET',
349+
body: '',
350+
);
351+
if (response == null) return null;
352+
debugPrint('getUserSubscription response: ${response.body}');
353+
if (response.statusCode == 200) {
354+
return UserSubscriptionResponse.fromJson(jsonDecode(response.body));
355+
}
356+
return null;
357+
}

app/lib/main.dart

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,16 +204,18 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
204204
ChangeNotifierProvider(create: (context) => ConversationProvider()),
205205
ListenableProvider(create: (context) => AppProvider()),
206206
ChangeNotifierProvider(create: (context) => PeopleProvider()),
207+
ChangeNotifierProvider(create: (context) => UsageProvider()),
207208
ListenableProvider(create: (context) => AppProvider()),
208209
ChangeNotifierProxyProvider<AppProvider, MessageProvider>(
209210
create: (context) => MessageProvider(),
210211
update: (BuildContext context, value, MessageProvider? previous) =>
211212
(previous?..updateAppProvider(value)) ?? MessageProvider(),
212213
),
213-
ChangeNotifierProxyProvider3<ConversationProvider, MessageProvider, PeopleProvider, CaptureProvider>(
214+
ChangeNotifierProxyProvider4<ConversationProvider, MessageProvider, PeopleProvider, UsageProvider,
215+
CaptureProvider>(
214216
create: (context) => CaptureProvider(),
215-
update: (BuildContext context, conversation, message, people, CaptureProvider? previous) =>
216-
(previous?..updateProviderInstances(conversation, message, people)) ?? CaptureProvider(),
217+
update: (BuildContext context, conversation, message, people, usage, CaptureProvider? previous) =>
218+
(previous?..updateProviderInstances(conversation, message, people, usage)) ?? CaptureProvider(),
217219
),
218220
ChangeNotifierProxyProvider<CaptureProvider, DeviceProvider>(
219221
create: (context) => DeviceProvider(),
@@ -247,7 +249,6 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
247249
ChangeNotifierProvider(create: (context) => PersonaProvider()),
248250
ChangeNotifierProvider(create: (context) => MemoriesProvider()),
249251
ChangeNotifierProvider(create: (context) => UserProvider()),
250-
ChangeNotifierProvider(create: (context) => UsageProvider()),
251252
ChangeNotifierProvider(create: (context) => ChatSessionProvider()),
252253
],
253254
builder: (context, child) {
@@ -387,6 +388,7 @@ class _DeciderWidgetState extends State<DeciderWidget> {
387388
context.read<MessageProvider>().setMessagesFromCache();
388389
context.read<AppProvider>().setAppsFromCache();
389390
context.read<MessageProvider>().refreshMessages();
391+
context.read<UsageProvider>().fetchSubscription();
390392
} else {
391393
if (!PlatformManager.instance.isAnalyticsSupported) {
392394
await PlatformManager.instance.intercom.loginUnidentifiedUser();

app/lib/models/subscription.dart

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import 'package:json_annotation/json_annotation.dart';
2+
3+
part 'subscription.g.dart';
4+
5+
enum PlanType {
6+
basic,
7+
unlimited,
8+
}
9+
10+
enum SubscriptionStatus {
11+
active,
12+
inactive,
13+
}
14+
15+
@JsonSerializable(fieldRename: FieldRename.snake)
16+
class Subscription {
17+
final PlanType plan;
18+
final SubscriptionStatus status;
19+
final int? currentPeriodEnd;
20+
final String? stripeSubscriptionId;
21+
@JsonKey(defaultValue: [])
22+
final List<String> features;
23+
@JsonKey(defaultValue: false)
24+
final bool cancelAtPeriodEnd;
25+
26+
Subscription({
27+
required this.plan,
28+
required this.status,
29+
this.currentPeriodEnd,
30+
this.stripeSubscriptionId,
31+
this.features = const [],
32+
this.cancelAtPeriodEnd = false,
33+
});
34+
35+
factory Subscription.fromJson(Map<String, dynamic> json) => _$SubscriptionFromJson(json);
36+
Map<String, dynamic> toJson() => _$SubscriptionToJson(this);
37+
}
38+
39+
@JsonSerializable(fieldRename: FieldRename.snake)
40+
class PricingOption {
41+
final String id;
42+
final String title;
43+
final String? description;
44+
final String priceString;
45+
46+
PricingOption({
47+
required this.id,
48+
required this.title,
49+
this.description,
50+
required this.priceString,
51+
});
52+
53+
factory PricingOption.fromJson(Map<String, dynamic> json) => _$PricingOptionFromJson(json);
54+
Map<String, dynamic> toJson() => _$PricingOptionToJson(this);
55+
}
56+
57+
@JsonSerializable(fieldRename: FieldRename.snake)
58+
class SubscriptionPlan {
59+
final String id;
60+
final String title;
61+
@JsonKey(defaultValue: [])
62+
final List<String> features;
63+
@JsonKey(defaultValue: [])
64+
final List<PricingOption> prices;
65+
66+
SubscriptionPlan({
67+
required this.id,
68+
required this.title,
69+
this.features = const [],
70+
this.prices = const [],
71+
});
72+
73+
factory SubscriptionPlan.fromJson(Map<String, dynamic> json) => _$SubscriptionPlanFromJson(json);
74+
Map<String, dynamic> toJson() => _$SubscriptionPlanToJson(this);
75+
}
76+
77+
@JsonSerializable(fieldRename: FieldRename.snake)
78+
class UserSubscriptionResponse {
79+
final Subscription subscription;
80+
final int transcriptionSecondsUsed;
81+
final int transcriptionSecondsLimit;
82+
final int wordsTranscribedUsed;
83+
final int wordsTranscribedLimit;
84+
final int insightsGainedUsed;
85+
final int insightsGainedLimit;
86+
final int memoriesCreatedUsed;
87+
final int memoriesCreatedLimit;
88+
@JsonKey(defaultValue: [])
89+
final List<SubscriptionPlan> availablePlans;
90+
@JsonKey(defaultValue: false)
91+
final bool showSubscriptionUi;
92+
93+
UserSubscriptionResponse({
94+
required this.subscription,
95+
required this.transcriptionSecondsUsed,
96+
required this.transcriptionSecondsLimit,
97+
required this.wordsTranscribedUsed,
98+
required this.wordsTranscribedLimit,
99+
required this.insightsGainedUsed,
100+
required this.insightsGainedLimit,
101+
required this.memoriesCreatedUsed,
102+
required this.memoriesCreatedLimit,
103+
this.availablePlans = const [],
104+
this.showSubscriptionUi = true,
105+
});
106+
107+
factory UserSubscriptionResponse.fromJson(Map<String, dynamic> json) => _$UserSubscriptionResponseFromJson(json);
108+
Map<String, dynamic> toJson() => _$UserSubscriptionResponseToJson(this);
109+
}

app/lib/models/subscription.g.dart

Lines changed: 115 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/lib/pages/conversations/widgets/processing_capture.dart

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:omi/providers/device_provider.dart';
1414
import 'package:omi/providers/onboarding_provider.dart';
1515
import 'package:omi/utils/analytics/mixpanel.dart';
1616
import 'package:omi/utils/enums.dart';
17+
import 'package:omi/pages/settings/usage_page.dart';
1718
import 'package:omi/utils/other/temp.dart';
1819
import 'package:omi/utils/platform/platform_service.dart';
1920

@@ -97,7 +98,7 @@ class _ConversationCaptureWidgetState extends State<ConversationCaptureWidget> w
9798

9899
return GestureDetector(
99100
onTap: () async {
100-
if (provider.segments.isEmpty && provider.photos.isEmpty) return;
101+
if (provider.outOfCredits || (provider.segments.isEmpty && provider.photos.isEmpty)) return;
101102
routeToPage(context, ConversationCapturingPage(topConversationId: topConvoId));
102103
},
103104
child: Container(

app/lib/pages/home/page.dart

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import 'package:omi/pages/conversation_capturing/page.dart';
4848

4949
import '../conversations/sync_page.dart';
5050
import 'widgets/battery_info_widget.dart';
51+
import 'widgets/out_of_credits_widget.dart';
5152

5253
class HomePageWrapper extends StatefulWidget {
5354
final String? navigateToRoute;
@@ -394,17 +395,22 @@ class _HomePageState extends State<HomePage> with WidgetsBindingObserver, Ticker
394395
},
395396
child: Stack(
396397
children: [
397-
Center(
398-
child: PageView(
399-
controller: _controller,
400-
physics: const NeverScrollableScrollPhysics(),
401-
children: const [
402-
ConversationsPage(),
403-
ActionItemsPage(),
404-
MemoriesPage(),
405-
AppsPage(),
406-
],
407-
),
398+
Column(
399+
children: [
400+
const OutOfCreditsWidget(),
401+
Expanded(
402+
child: PageView(
403+
controller: _controller,
404+
physics: const NeverScrollableScrollPhysics(),
405+
children: const [
406+
ConversationsPage(),
407+
ActionItemsPage(),
408+
MemoriesPage(),
409+
AppsPage(),
410+
],
411+
),
412+
),
413+
],
408414
),
409415
Consumer2<HomeProvider, DeviceProvider>(
410416
builder: (context, home, deviceProvider, child) {

0 commit comments

Comments
 (0)