Implement Android subscription system with freemium limitations
Major subscription system implementation for Android: BillingManager (Android): - Full Google Play Billing Library integration - Product loading, purchase flow, and acknowledgment - Backend verification via APILayer.verifyAndroidPurchase() - Purchase restoration for returning users - Error handling and connection state management SubscriptionHelper (Shared): - New limit checking methods: isResidencesBlocked(), isTasksBlocked(), isContractorsBlocked(), isDocumentsBlocked() - Add permission checks: canAddProperty(), canAddTask(), canAddContractor(), canAddDocument() - Enforces freemium rules based on backend limitationsEnabled flag Screen Updates: - ContractorsScreen: Show upgrade prompt when contractors limit=0 - DocumentsScreen: Show upgrade prompt when documents limit=0 - ResidencesScreen: Show upgrade prompt when properties limit reached - ResidenceDetailScreen: Show upgrade prompt when tasks limit reached UpgradeFeatureScreen: - Enhanced with feature benefits comparison - Dynamic content from backend upgrade triggers - Platform-specific purchase buttons Additional changes: - DataCache: Added O(1) lookup maps for ID resolution - New minimal models (TaskMinimal, ContractorMinimal, ResidenceMinimal) - TaskApi: Added archive/unarchive endpoints - Added Google Billing Library dependency - iOS SubscriptionCache and UpgradePromptView updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -69,10 +69,18 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Initialize with current values from Kotlin cache
|
||||
Task {
|
||||
await observeSubscriptionStatus()
|
||||
await observeUpgradeTriggers()
|
||||
// Start observation of Kotlin cache
|
||||
Task { @MainActor in
|
||||
// Initial sync
|
||||
self.observeSubscriptionStatusSync()
|
||||
self.observeUpgradeTriggersSync()
|
||||
|
||||
// Poll for updates periodically (workaround for Kotlin StateFlow observation)
|
||||
while true {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
|
||||
self.observeSubscriptionStatusSync()
|
||||
self.observeUpgradeTriggersSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +107,24 @@ class SubscriptionCacheWrapper: ObservableObject {
|
||||
}
|
||||
|
||||
func refreshFromCache() {
|
||||
Task {
|
||||
await observeSubscriptionStatus()
|
||||
await observeUpgradeTriggers()
|
||||
Task { @MainActor in
|
||||
observeSubscriptionStatusSync()
|
||||
observeUpgradeTriggersSync()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func observeSubscriptionStatusSync() {
|
||||
if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus {
|
||||
self.currentSubscription = subscription
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func observeUpgradeTriggersSync() {
|
||||
let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData]
|
||||
if let triggers = kotlinTriggers {
|
||||
self.upgradeTriggers = triggers
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,119 @@ import SwiftUI
|
||||
import ComposeApp
|
||||
import StoreKit
|
||||
|
||||
// MARK: - Promo Content View
|
||||
|
||||
struct PromoContentView: View {
|
||||
let content: String
|
||||
|
||||
private var lines: [PromoLine] {
|
||||
parseContent(content)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(Array(lines.enumerated()), id: \.offset) { _, line in
|
||||
switch line {
|
||||
case .emoji(let text):
|
||||
Text(text)
|
||||
.font(.system(size: 36))
|
||||
|
||||
case .title(let text):
|
||||
Text(text)
|
||||
.font(.title3.bold())
|
||||
.foregroundColor(Color.appPrimary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
case .body(let text):
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextSecondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
case .checkItem(let text):
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(Color.appPrimary)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(Color.appTextPrimary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
case .italic(let text):
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.italic()
|
||||
.foregroundColor(Color.appAccent)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
case .spacer:
|
||||
Spacer().frame(height: 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum PromoLine {
|
||||
case emoji(String)
|
||||
case title(String)
|
||||
case body(String)
|
||||
case checkItem(String)
|
||||
case italic(String)
|
||||
case spacer
|
||||
}
|
||||
|
||||
private func parseContent(_ content: String) -> [PromoLine] {
|
||||
var result: [PromoLine] = []
|
||||
let lines = content.components(separatedBy: "\n")
|
||||
|
||||
for line in lines {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||
|
||||
if trimmed.isEmpty {
|
||||
result.append(.spacer)
|
||||
} else if trimmed.hasPrefix("✓") {
|
||||
let text = trimmed.dropFirst().trimmingCharacters(in: .whitespaces)
|
||||
result.append(.checkItem(text))
|
||||
} else if trimmed.contains("<b>") && trimmed.contains("</b>") {
|
||||
// Title line with emoji
|
||||
let cleaned = trimmed
|
||||
.replacingOccurrences(of: "<b>", with: "")
|
||||
.replacingOccurrences(of: "</b>", with: "")
|
||||
|
||||
// Check if starts with emoji
|
||||
if let firstScalar = cleaned.unicodeScalars.first,
|
||||
firstScalar.properties.isEmoji && !firstScalar.properties.isASCIIHexDigit {
|
||||
// Split emoji and title
|
||||
let parts = cleaned.split(separator: " ", maxSplits: 1)
|
||||
if parts.count == 2 {
|
||||
result.append(.emoji(String(parts[0])))
|
||||
result.append(.title(String(parts[1])))
|
||||
} else {
|
||||
result.append(.title(cleaned))
|
||||
}
|
||||
} else {
|
||||
result.append(.title(cleaned))
|
||||
}
|
||||
} else if trimmed.hasPrefix("<i>") && trimmed.hasSuffix("</i>") {
|
||||
let text = trimmed
|
||||
.replacingOccurrences(of: "<i>", with: "")
|
||||
.replacingOccurrences(of: "</i>", with: "")
|
||||
result.append(.italic(text))
|
||||
} else if trimmed.first?.unicodeScalars.first?.properties.isEmoji == true &&
|
||||
trimmed.count <= 2 {
|
||||
// Standalone emoji
|
||||
result.append(.emoji(trimmed))
|
||||
} else {
|
||||
result.append(.body(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
struct UpgradePromptView: View {
|
||||
let triggerKey: String
|
||||
@Binding var isPresented: Bool
|
||||
@@ -42,14 +155,22 @@ struct UpgradePromptView: View {
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
// Pro Features Preview
|
||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
||||
FeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
FeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
// Pro Features Preview - Dynamic content or fallback
|
||||
Group {
|
||||
if let promoContent = triggerData?.promoHtml, !promoContent.isEmpty {
|
||||
PromoContentView(content: promoContent)
|
||||
.padding()
|
||||
} else {
|
||||
// Fallback to static features if no promo content
|
||||
VStack(alignment: .leading, spacing: AppSpacing.md) {
|
||||
FeatureRow(icon: "house.fill", text: "Unlimited properties")
|
||||
FeatureRow(icon: "checkmark.circle.fill", text: "Unlimited tasks")
|
||||
FeatureRow(icon: "person.2.fill", text: "Contractor management")
|
||||
FeatureRow(icon: "doc.fill", text: "Document & warranty storage")
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.appBackgroundSecondary)
|
||||
.cornerRadius(AppRadius.lg)
|
||||
.padding(.horizontal)
|
||||
@@ -153,6 +274,8 @@ struct UpgradePromptView: View {
|
||||
Text("You now have full access to all Pro features!")
|
||||
}
|
||||
.task {
|
||||
// Refresh subscription cache to get latest upgrade triggers
|
||||
subscriptionCache.refreshFromCache()
|
||||
await storeKit.loadProducts()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user