Optimize subscription tier management and empty state logic

Changes:
- Make currentTier a computed property from StoreKit instead of stored value
- Initialize StoreKit at app launch and refresh on foreground for ready subscription status
- Fix empty state logic: show empty state when limit > 0, upgrade prompt when limit = 0
- Add subscription status display in Profile screen (iOS/Android)
- Add upgrade prompts to Residences and Tasks screens for free tier users
- Improve SubscriptionHelper with better tier checking logic

iOS:
- SubscriptionCache: currentTier now computed from StoreKit.purchasedProductIDs
- StoreKitManager: add refreshSubscriptionStatus() method
- AppDelegate: initialize StoreKit at launch and refresh on app active
- ContractorsListView: use shouldShowUpgradePrompt(currentCount:limitKey:)
- WarrantiesTabContent/DocumentsTabContent: same empty state fix
- ProfileTabView: display current tier and limitations status
- ResidencesListView/ResidenceDetailView: add upgrade prompts for free users
- AllTasksView: add upgrade prompt for free users

Android:
- ContractorsScreen/DocumentsScreen: fix empty state logic
- ProfileScreen: display subscription status and limits
- ResidencesScreen/ResidenceDetailScreen: add upgrade prompts
- UpgradeFeatureScreen: improve UI layout

Shared:
- APILayer: add getSubscriptionStatus() method
- SubscriptionHelper: add hasAccessToFeature() utility
- Remove duplicate subscription checking logic

🤖 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-24 18:59:09 -06:00
parent d92a4fd4f1
commit ce1ca0f0ce
23 changed files with 728 additions and 120 deletions

View File

@@ -3,11 +3,13 @@ import ComposeApp
struct ContractorsListView: View {
@StateObject private var viewModel = ContractorViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var searchText = ""
@State private var showingAddSheet = false
@State private var selectedSpecialty: String? = nil
@State private var showFavoritesOnly = false
@State private var showSpecialtyFilter = false
@State private var showingUpgradePrompt = false
// Lookups from DataCache
@State private var contractorSpecialties: [ContractorSpecialty] = []
@@ -73,9 +75,18 @@ struct ContractorsListView: View {
Spacer()
} else if contractors.isEmpty {
Spacer()
EmptyContractorsView(
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
)
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "contractors") {
// User can add contractors (limit > 0) - show empty state
EmptyContractorsView(
hasFilters: showFavoritesOnly || selectedSpecialty != nil || !searchText.isEmpty
)
} else {
// User is blocked (limit = 0) - show upgrade prompt
UpgradeFeatureView(
triggerKey: "view_contractors",
icon: "person.2.fill"
)
}
Spacer()
} else {
ScrollView {
@@ -143,7 +154,15 @@ struct ContractorsListView: View {
}
// Add Button
Button(action: { showingAddSheet = true }) {
Button(action: {
// Check LIVE contractor count before adding
let currentCount = viewModel.contractors.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "contractors") {
showingUpgradePrompt = true
} else {
showingAddSheet = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundColor(Color.appPrimary)
@@ -161,6 +180,9 @@ struct ContractorsListView: View {
)
.presentationDetents([.large])
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_contractors", isPresented: $showingUpgradePrompt)
}
.onAppear {
loadContractors()
loadContractorSpecialties()

View File

@@ -3,6 +3,7 @@ import ComposeApp
struct DocumentsTabContent: View {
@ObservedObject var viewModel: DocumentViewModel
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
let searchText: String
var filteredDocuments: [Document] {
@@ -27,11 +28,20 @@ struct DocumentsTabContent: View {
Spacer()
} else if filteredDocuments.isEmpty {
Spacer()
EmptyStateView(
icon: "doc",
title: "No documents found",
message: "Add documents related to your residence"
)
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
// User can add documents (limit > 0) - show empty state
EmptyStateView(
icon: "doc",
title: "No documents found",
message: "Add documents related to your residence"
)
} else {
// User is blocked (limit = 0) - show upgrade prompt
UpgradeFeatureView(
triggerKey: "view_documents",
icon: "doc.text.fill"
)
}
Spacer()
} else {
ScrollView {

View File

@@ -3,6 +3,7 @@ import ComposeApp
struct WarrantiesTabContent: View {
@ObservedObject var viewModel: DocumentViewModel
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
let searchText: String
var filteredWarranties: [Document] {
@@ -29,11 +30,20 @@ struct WarrantiesTabContent: View {
Spacer()
} else if filteredWarranties.isEmpty {
Spacer()
EmptyStateView(
icon: "doc.text.viewfinder",
title: "No warranties found",
message: "Add warranties to track coverage periods"
)
if !subscriptionCache.shouldShowUpgradePrompt(currentCount: 0, limitKey: "documents") {
// User can add documents (limit > 0) - show empty state
EmptyStateView(
icon: "doc.text.viewfinder",
title: "No warranties found",
message: "Add warranties to track coverage periods"
)
} else {
// User is blocked (limit = 0) - show upgrade prompt
UpgradeFeatureView(
triggerKey: "view_documents",
icon: "doc.text.fill"
)
}
Spacer()
} else {
ScrollView {

View File

@@ -8,6 +8,7 @@ enum DocumentWarrantyTab {
struct DocumentsWarrantiesView: View {
@StateObject private var documentViewModel = DocumentViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var selectedTab: DocumentWarrantyTab = .warranties
@State private var searchText = ""
@State private var selectedCategory: String? = nil
@@ -15,6 +16,7 @@ struct DocumentsWarrantiesView: View {
@State private var showActiveOnly = true
@State private var showFilterMenu = false
@State private var showAddSheet = false
@State private var showingUpgradePrompt = false
let residenceId: Int32?
@@ -154,7 +156,13 @@ struct DocumentsWarrantiesView: View {
// Add Button
Button(action: {
showAddSheet = true
// Check LIVE document count before adding
let currentCount = documentViewModel.documents.count
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "documents") {
showingUpgradePrompt = true
} else {
showAddSheet = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title2)
@@ -182,6 +190,9 @@ struct DocumentsWarrantiesView: View {
documentViewModel: documentViewModel
)
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "view_documents", isPresented: $showingUpgradePrompt)
}
}
private func loadWarranties() {

View File

@@ -1,10 +1,15 @@
import SwiftUI
import ComposeApp
struct ProfileTabView: View {
@EnvironmentObject private var themeManager: ThemeManager
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@StateObject private var storeKitManager = StoreKitManager.shared
@State private var showingProfileEdit = false
@State private var showingLogoutAlert = false
@State private var showingThemeSelection = false
@State private var showUpgradePrompt = false
@State private var showRestoreSuccess = false
var body: some View {
List {
@@ -47,6 +52,61 @@ struct ProfileTabView: View {
}
.listRowBackground(Color.appBackgroundSecondary)
// Subscription Section - Only show if limitations are enabled on backend
if let subscription = subscriptionCache.currentSubscription, subscription.limitationsEnabled {
Section("Subscription") {
HStack {
Image(systemName: "crown.fill")
.foregroundColor(subscriptionCache.currentTier == "pro" ? Color.appAccent : Color.appTextSecondary)
VStack(alignment: .leading, spacing: 4) {
Text(subscriptionCache.currentTier == "pro" ? "Pro Plan" : "Free Plan")
.font(.headline)
.foregroundColor(Color.appTextPrimary)
if subscriptionCache.currentTier == "pro",
let expiresAt = subscription.expiresAt {
Text("Active until \(formatDate(expiresAt))")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
} else {
Text("Limited features")
.font(.caption)
.foregroundColor(Color.appTextSecondary)
}
}
}
.padding(.vertical, 4)
if subscriptionCache.currentTier != "pro" {
Button(action: { showUpgradePrompt = true }) {
Label("Upgrade to Pro", systemImage: "arrow.up.circle.fill")
.foregroundColor(Color.appPrimary)
}
} else {
Button(action: {
if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
UIApplication.shared.open(url)
}
}) {
Label("Manage Subscription", systemImage: "gearshape.fill")
.foregroundColor(Color.appTextPrimary)
}
}
Button(action: {
Task {
await storeKitManager.restorePurchases()
showRestoreSuccess = true
}
}) {
Label("Restore Purchases", systemImage: "arrow.clockwise")
.foregroundColor(Color.appTextSecondary)
}
}
.listRowBackground(Color.appBackgroundSecondary)
}
Section("Appearance") {
Button(action: {
showingThemeSelection = true
@@ -111,5 +171,23 @@ struct ProfileTabView: View {
} message: {
Text("Are you sure you want to log out?")
}
.sheet(isPresented: $showUpgradePrompt) {
UpgradePromptView(triggerKey: "user_profile", isPresented: $showUpgradePrompt)
}
.alert("Purchases Restored", isPresented: $showRestoreSuccess) {
Button("OK", role: .cancel) { }
} message: {
Text("Your purchases have been restored successfully.")
}
}
private func formatDate(_ dateString: String) -> String {
let formatter = ISO8601DateFormatter()
if let date = formatter.date(from: dateString) {
let displayFormatter = DateFormatter()
displayFormatter.dateStyle = .medium
return displayFormatter.string(from: date)
}
return dateString
}
}

View File

@@ -21,6 +21,13 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
PushNotificationManager.shared.clearBadge()
}
// Initialize StoreKit and check for existing subscriptions
// This ensures we have the user's subscription status ready before they interact
Task {
_ = StoreKitManager.shared
print("✅ StoreKit initialized at app launch")
}
return true
}
@@ -31,6 +38,12 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
Task { @MainActor in
PushNotificationManager.shared.clearBadge()
}
// Refresh StoreKit subscription status when app comes to foreground
// This ensures we have the latest subscription state if it changed while app was in background
Task {
await StoreKitManager.shared.refreshSubscriptionStatus()
}
}
// MARK: - Remote Notifications

View File

@@ -25,7 +25,9 @@ struct ResidenceDetailView: View {
@State private var showReportConfirmation = false
@State private var showDeleteConfirmation = false
@State private var isDeleting = false
@State private var showingUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -120,6 +122,9 @@ struct ResidenceDetailView: View {
Text("Are you sure you want to archive \"\(task.title)\"? You can unarchive it later from archived tasks.")
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
// MARK: onChange & lifecycle
.onChange(of: viewModel.reportMessage) { message in
@@ -255,7 +260,13 @@ private extension ResidenceDetailView {
}
Button {
showAddTask = true
// Check LIVE task count before adding
let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTasks, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
} label: {
Image(systemName: "plus")
}

View File

@@ -5,7 +5,9 @@ struct ResidencesListView: View {
@StateObject private var viewModel = ResidenceViewModel()
@State private var showingAddResidence = false
@State private var showingJoinResidence = false
@State private var showingUpgradePrompt = false
@StateObject private var authManager = AuthenticationManager.shared
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
var body: some View {
@@ -71,7 +73,13 @@ struct ResidencesListView: View {
.toolbar {
ToolbarItemGroup(placement: .navigationBarTrailing) {
Button(action: {
showingJoinResidence = true
// Check if we should show upgrade prompt before joining
let currentCount = viewModel.myResidences?.residences.count ?? 0
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
showingUpgradePrompt = true
} else {
showingJoinResidence = true
}
}) {
Image(systemName: "person.badge.plus")
.font(.system(size: 18, weight: .semibold))
@@ -79,7 +87,13 @@ struct ResidencesListView: View {
}
Button(action: {
showingAddResidence = true
// Check if we should show upgrade prompt before adding
let currentCount = viewModel.myResidences?.residences.count ?? 0
if subscriptionCache.shouldShowUpgradePrompt(currentCount: currentCount, limitKey: "properties") {
showingUpgradePrompt = true
} else {
showingAddResidence = true
}
}) {
Image(systemName: "plus.circle.fill")
.font(.system(size: 22, weight: .semibold))
@@ -101,6 +115,9 @@ struct ResidencesListView: View {
viewModel.loadMyResidences()
})
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_second_property", isPresented: $showingUpgradePrompt)
}
.onAppear {
if authManager.isAuthenticated {
viewModel.loadMyResidences()

View File

@@ -128,6 +128,13 @@ class StoreKitManager: ObservableObject {
return false
}
/// Refresh subscription status from StoreKit
/// Call this when app comes to foreground to ensure we have latest status
func refreshSubscriptionStatus() async {
await updatePurchasedProducts()
print("🔄 StoreKit: Subscription status refreshed")
}
/// Update purchased product IDs
@MainActor
private func updatePurchasedProducts() async {

View File

@@ -4,24 +4,104 @@ import ComposeApp
/// Swift wrapper for accessing Kotlin SubscriptionCache
class SubscriptionCacheWrapper: ObservableObject {
static let shared = SubscriptionCacheWrapper()
@Published var currentSubscription: SubscriptionStatus?
@Published var upgradeTriggers: [String: UpgradeTriggerData] = [:]
@Published var featureBenefits: [FeatureBenefit] = []
@Published var promotions: [Promotion] = []
/// Current tier based on StoreKit purchases
var currentTier: String {
// Check if user has any active subscriptions via StoreKit
return StoreKitManager.shared.purchasedProductIDs.isEmpty ? "free" : "pro"
}
/// Check if user should be blocked from adding an item based on LIVE count
/// - Parameters:
/// - currentCount: The actual current count from the data (e.g., viewModel.residences.count)
/// - limitKey: The key to check ("properties", "tasks", "contractors", or "documents")
/// - Returns: true if should show upgrade prompt (blocked), false if allowed
func shouldShowUpgradePrompt(currentCount: Int, limitKey: String) -> Bool {
// If limitations are disabled globally, never block
guard let subscription = currentSubscription, subscription.limitationsEnabled else {
return false
}
// Pro tier never gets blocked
if currentTier == "pro" {
return false
}
// Get the appropriate limits for the current tier from StoreKit
guard let tierLimits = subscription.limits[currentTier] else {
print("⚠️ No limits found for tier: \(currentTier)")
return false
}
// Get the specific limit for this resource type
let limit: Int?
switch limitKey {
case "properties":
limit = tierLimits.properties != nil ? Int(truncating: tierLimits.properties!) : nil
case "tasks":
limit = tierLimits.tasks != nil ? Int(truncating: tierLimits.tasks!) : nil
case "contractors":
limit = tierLimits.contractors != nil ? Int(truncating: tierLimits.contractors!) : nil
case "documents":
limit = tierLimits.documents != nil ? Int(truncating: tierLimits.documents!) : nil
default:
print("⚠️ Unknown limit key: \(limitKey)")
return false
}
// nil limit means unlimited
guard let actualLimit = limit else {
return false
}
// Block if current count >= actualLimit
return currentCount >= Int(actualLimit)
}
/// Deprecated: Use shouldShowUpgradePrompt(currentCount:limitKey:) instead
var shouldShowUpgradePrompt: Bool {
currentTier == "free" && (currentSubscription?.limitationsEnabled ?? false)
}
private init() {
// Initialize with current values from Kotlin cache
Task {
await observeSubscriptionStatus()
await observeUpgradeTriggers()
}
}
@MainActor
private func observeSubscriptionStatus() {
// Update from Kotlin cache
if let subscription = ComposeApp.SubscriptionCache.shared.currentSubscription.value as? SubscriptionStatus {
self.currentSubscription = subscription
print("📊 Subscription Status: currentTier=\(currentTier), limitationsEnabled=\(subscription.limitationsEnabled)")
print(" 📊 Free Tier Limits - Properties: \(subscription.limits["free"]?.properties), Tasks: \(subscription.limits["free"]?.tasks), Contractors: \(subscription.limits["free"]?.contractors), Documents: \(subscription.limits["free"]?.documents)")
print(" 📊 Pro Tier Limits - Properties: \(subscription.limits["pro"]?.properties), Tasks: \(subscription.limits["pro"]?.tasks), Contractors: \(subscription.limits["pro"]?.contractors), Documents: \(subscription.limits["pro"]?.documents)")
} else {
print("⚠️ No subscription status in cache")
}
}
@MainActor
private func observeUpgradeTriggers() {
// Update from Kotlin cache
let kotlinTriggers = ComposeApp.SubscriptionCache.shared.upgradeTriggers.value as? [String: UpgradeTriggerData]
if let triggers = kotlinTriggers {
self.upgradeTriggers = triggers
}
}
func refreshFromCache() {
Task {
await observeSubscriptionStatus()
await observeUpgradeTriggers()
}
}
@@ -42,3 +122,4 @@ class SubscriptionCacheWrapper: ObservableObject {
}
}
}

View File

@@ -1,25 +0,0 @@
import Foundation
import ComposeApp
/// Swift wrapper for Kotlin SubscriptionHelper
class SubscriptionHelper {
static func canAddProperty() -> (allowed: Bool, triggerKey: String?) {
let result = ComposeApp.SubscriptionHelper.shared.canAddProperty()
return (result.allowed, result.triggerKey)
}
static func canAddTask() -> (allowed: Bool, triggerKey: String?) {
let result = ComposeApp.SubscriptionHelper.shared.canAddTask()
return (result.allowed, result.triggerKey)
}
static func shouldShowUpgradePromptForContractors() -> (showPrompt: Bool, triggerKey: String?) {
let result = ComposeApp.SubscriptionHelper.shared.shouldShowUpgradePromptForContractors()
return (result.allowed, result.triggerKey)
}
static func shouldShowUpgradePromptForDocuments() -> (showPrompt: Bool, triggerKey: String?) {
let result = ComposeApp.SubscriptionHelper.shared.shouldShowUpgradePromptForDocuments()
return (result.allowed, result.triggerKey)
}
}

View File

@@ -3,34 +3,52 @@ import ComposeApp
struct UpgradeFeatureView: View {
let triggerKey: String
let featureName: String
let featureDescription: String
let icon: String
@State private var showUpgradePrompt = false
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
// Look up trigger data from cache
private var triggerData: UpgradeTriggerData? {
subscriptionCache.upgradeTriggers[triggerKey]
}
// Fallback values if trigger not found
private var title: String {
triggerData?.title ?? "Upgrade Required"
}
private var message: String {
triggerData?.message ?? "This feature is available with a Pro subscription."
}
private var buttonText: String {
triggerData?.buttonText ?? "Upgrade to Pro"
}
var body: some View {
VStack(spacing: AppSpacing.xl) {
Spacer()
// Feature Icon
Image(systemName: icon)
.font(.system(size: 80))
.foregroundStyle(Color.appPrimary.gradient)
// Title
Text(featureName)
Text(title)
.font(.title.weight(.bold))
.foregroundColor(Color.appTextPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl)
// Description
Text(featureDescription)
Text(message)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
.padding(.horizontal, AppSpacing.xl)
// Upgrade Message
Text("This feature is available with Pro")
.font(.subheadline.weight(.medium))
@@ -39,12 +57,12 @@ struct UpgradeFeatureView: View {
.padding(.vertical, AppSpacing.sm)
.background(Color.appAccent.opacity(0.1))
.cornerRadius(AppRadius.md)
// Upgrade Button
Button(action: {
showUpgradePrompt = true
}) {
Text("Upgrade to Pro")
Text(buttonText)
.font(.headline)
.foregroundColor(Color.appTextOnPrimary)
.frame(maxWidth: .infinity)
@@ -54,7 +72,7 @@ struct UpgradeFeatureView: View {
}
.padding(.horizontal, AppSpacing.xl)
.padding(.top, AppSpacing.lg)
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -68,8 +86,6 @@ struct UpgradeFeatureView: View {
#Preview {
UpgradeFeatureView(
triggerKey: "view_contractors",
featureName: "Contractors",
featureDescription: "Track and manage all your contractors in one place",
icon: "person.2.fill"
)
}

View File

@@ -4,20 +4,28 @@ import ComposeApp
struct AllTasksView: View {
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var tasksResponse: TaskColumnsResponse?
@State private var isLoadingTasks = false
@State private var tasksError: String?
@State private var showAddTask = false
@State private var showEditTask = false
@State private var showingUpgradePrompt = false
@State private var selectedTaskForEdit: TaskDetail?
@State private var selectedTaskForComplete: TaskDetail?
@State private var selectedTaskForArchive: TaskDetail?
@State private var showArchiveConfirmation = false
@State private var selectedTaskForCancel: TaskDetail?
@State private var showCancelConfirmation = false
// Count total tasks across all columns
private var totalTaskCount: Int {
guard let response = tasksResponse else { return 0 }
return response.columns.reduce(0) { $0 + $1.tasks.count }
}
private var hasNoTasks: Bool {
guard let response = tasksResponse else { return true }
return response.columns.allSatisfy { $0.tasks.isEmpty }
@@ -46,6 +54,9 @@ struct AllTasksView: View {
loadAllTasks()
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
.alert("Archive Task", isPresented: $showArchiveConfirmation) {
Button("Cancel", role: .cancel) {
selectedTaskForArchive = nil
@@ -129,7 +140,12 @@ struct AllTasksView: View {
.multilineTextAlignment(.center)
Button(action: {
showAddTask = true
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
@@ -224,7 +240,12 @@ struct AllTasksView: View {
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showAddTask = true
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
Image(systemName: "plus")
}