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:
Trey t
2025-11-25 11:23:53 -06:00
parent f9e522f734
commit 7b0a0e5d85
21 changed files with 2316 additions and 549 deletions

View File

@@ -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
}
}

View File

@@ -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()
}
}