diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6b53bcd..a043cdb 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -72,6 +72,8 @@ Join Property Enter Share Code Join + Pro Feature + Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance. Property Members Owner Member @@ -248,6 +250,8 @@ Are you sure you want to delete this contractor? This action cannot be undone. %1$d completed tasks Share Contractor + Pro Feature + Sharing contractors is a Pro feature. Upgrade to share your trusted contractors with friends and family. Import Contractor Would you like to import this contractor? Contractor Imported @@ -368,6 +372,14 @@ Support Contact Support Get help with your account + Unlock Premium Features + Upgrade to Pro for the complete experience + Unlimited Properties + Document & Warranty Storage + Residence Sharing + Contractor Sharing + Actionable Notifications + Home Screen Widgets Settings @@ -516,6 +528,10 @@ Keep all your contractor contacts organized and easily accessible Share With Family Invite family members to collaborate on home maintenance together + Smart Notifications + Get actionable reminders that let you complete tasks right from the notification + Home Screen Widgets + Quick access to tasks and reminders directly from your home screen Name Your Home @@ -570,6 +586,12 @@ Get everyone on the same page Spending Insights See where your money goes + Contractor Sharing + Share your trusted contractors with family and friends + Home Screen Widgets + Quick actions right from your home screen + Actionable Notifications + Complete tasks directly from notifications Choose your plan Monthly Yearly diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt index 76d0fb9..05983e0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ContractorDetailScreen.kt @@ -29,6 +29,8 @@ import com.example.casera.util.DateUtils import com.example.casera.viewmodel.ContractorViewModel import com.example.casera.network.ApiResult import com.example.casera.platform.rememberShareContractor +import com.example.casera.utils.SubscriptionHelper +import com.example.casera.ui.subscription.UpgradePromptDialog import casera.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -45,6 +47,8 @@ fun ContractorDetailScreen( var showEditDialog by remember { mutableStateOf(false) } var showDeleteConfirmation by remember { mutableStateOf(false) } + var showUpgradePrompt by remember { mutableStateOf(false) } + var upgradeTriggerKey by remember { mutableStateOf(null) } val shareContractor = rememberShareContractor() @@ -90,7 +94,15 @@ fun ContractorDetailScreen( actions = { when (val state = contractorState) { is ApiResult.Success -> { - IconButton(onClick = { shareContractor(state.data) }) { + IconButton(onClick = { + val shareCheck = SubscriptionHelper.canShareContractor() + if (shareCheck.allowed) { + shareContractor(state.data) + } else { + upgradeTriggerKey = shareCheck.triggerKey + showUpgradePrompt = true + } + }) { Icon(Icons.Default.Share, stringResource(Res.string.common_share)) } IconButton(onClick = { viewModel.toggleFavorite(contractorId) }) { @@ -557,6 +569,21 @@ fun ContractorDetailScreen( shape = RoundedCornerShape(16.dp) ) } + + if (showUpgradePrompt && upgradeTriggerKey != null) { + UpgradePromptDialog( + triggerKey = upgradeTriggerKey!!, + onDismiss = { + showUpgradePrompt = false + upgradeTriggerKey = null + }, + onUpgrade = { + // TODO: Navigate to subscription purchase screen + showUpgradePrompt = false + upgradeTriggerKey = null + } + ) + } } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt index 19c4915..b15b3e7 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ProfileScreen.kt @@ -345,6 +345,44 @@ fun ProfileScreen( } if (SubscriptionHelper.currentTier != "pro") { + // Upgrade Benefits List + Column( + modifier = Modifier.padding(vertical = AppSpacing.sm), + verticalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Text( + text = stringResource(Res.string.profile_upgrade_benefits_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + + UpgradeBenefitRow( + icon = Icons.Default.Home, + text = stringResource(Res.string.profile_benefit_unlimited_properties) + ) + UpgradeBenefitRow( + icon = Icons.Default.Folder, + text = stringResource(Res.string.profile_benefit_document_vault) + ) + UpgradeBenefitRow( + icon = Icons.Default.People, + text = stringResource(Res.string.profile_benefit_residence_sharing) + ) + UpgradeBenefitRow( + icon = Icons.Default.Share, + text = stringResource(Res.string.profile_benefit_contractor_sharing) + ) + UpgradeBenefitRow( + icon = Icons.Default.Notifications, + text = stringResource(Res.string.profile_benefit_actionable_notifications) + ) + UpgradeBenefitRow( + icon = Icons.Default.Widgets, + text = stringResource(Res.string.profile_benefit_widgets) + ) + } + Button( onClick = { showUpgradePrompt = true }, modifier = Modifier.fillMaxWidth(), @@ -556,3 +594,26 @@ fun ProfileScreen( } } } + +@Composable +private fun UpgradeBenefitRow( + icon: androidx.compose.ui.graphics.vector.ImageVector, + text: String +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(AppSpacing.sm) + ) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt index 35e4260..ef5cdb0 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResidenceDetailScreen.kt @@ -442,7 +442,15 @@ fun ResidenceDetailScreen( // Share button - only show for primary owners if (residence.ownerId == currentUser?.id) { IconButton( - onClick = { shareResidence(residence) }, + onClick = { + val shareCheck = SubscriptionHelper.canShareResidence() + if (shareCheck.allowed) { + shareResidence(residence) + } else { + upgradeTriggerKey = shareCheck.triggerKey + showUpgradePrompt = true + } + }, enabled = !shareState.isLoading ) { if (shareState.isLoading) { @@ -459,7 +467,13 @@ fun ResidenceDetailScreen( // Manage Users button - only show for primary owners if (residence.ownerId == currentUser?.id) { IconButton(onClick = { - showManageUsersDialog = true + val shareCheck = SubscriptionHelper.canShareResidence() + if (shareCheck.allowed) { + showManageUsersDialog = true + } else { + upgradeTriggerKey = shareCheck.triggerKey + showUpgradePrompt = true + } }) { Icon(Icons.Default.People, contentDescription = stringResource(Res.string.properties_manage_users)) } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt index 965008c..a3ffb56 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingSubscriptionContent.kt @@ -70,15 +70,6 @@ fun OnboardingSubscriptionContent( MaterialTheme.colorScheme.secondary ) ), - SubscriptionBenefit( - icon = Icons.Default.Notifications, - title = stringResource(Res.string.onboarding_subscription_benefit_reminders), - description = stringResource(Res.string.onboarding_subscription_benefit_reminders_desc), - gradientColors = listOf( - MaterialTheme.colorScheme.tertiary, - Color(0xFFFF9500) - ) - ), SubscriptionBenefit( icon = Icons.Default.Folder, title = stringResource(Res.string.onboarding_subscription_benefit_documents), @@ -98,13 +89,31 @@ fun OnboardingSubscriptionContent( ) ), SubscriptionBenefit( - icon = Icons.Default.TrendingUp, - title = stringResource(Res.string.onboarding_subscription_benefit_insights), - description = stringResource(Res.string.onboarding_subscription_benefit_insights_desc), + icon = Icons.Default.Share, + title = stringResource(Res.string.onboarding_subscription_benefit_contractor_sharing), + description = stringResource(Res.string.onboarding_subscription_benefit_contractor_sharing_desc), gradientColors = listOf( - MaterialTheme.colorScheme.error, + MaterialTheme.colorScheme.tertiary, + Color(0xFFFF9500) + ) + ), + SubscriptionBenefit( + icon = Icons.Default.Notifications, + title = stringResource(Res.string.onboarding_subscription_benefit_notifications), + description = stringResource(Res.string.onboarding_subscription_benefit_notifications_desc), + gradientColors = listOf( + Color(0xFFFF3B30), Color(0xFFFF6961) ) + ), + SubscriptionBenefit( + icon = Icons.Default.Widgets, + title = stringResource(Res.string.onboarding_subscription_benefit_widgets), + description = stringResource(Res.string.onboarding_subscription_benefit_widgets_desc), + gradientColors = listOf( + Color(0xFF007AFF), + Color(0xFF5AC8FA) + ) ) ) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt index a7f3943..fbc5502 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/onboarding/OnboardingValuePropsContent.kt @@ -75,6 +75,24 @@ fun OnboardingValuePropsContent( MaterialTheme.colorScheme.primary, MaterialTheme.colorScheme.tertiary ) + ), + FeatureItem( + icon = Icons.Default.Notifications, + title = stringResource(Res.string.onboarding_feature_notifications_title), + description = stringResource(Res.string.onboarding_feature_notifications_desc), + gradientColors = listOf( + Color(0xFFFF9500), + Color(0xFFFF6961) + ) + ), + FeatureItem( + icon = Icons.Default.Widgets, + title = stringResource(Res.string.onboarding_feature_widgets_title), + description = stringResource(Res.string.onboarding_feature_widgets_desc), + gradientColors = listOf( + Color(0xFF34C759), + Color(0xFF30D158) + ) ) ) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt b/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt index 703acee..a33d0e6 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/utils/SubscriptionHelper.kt @@ -267,6 +267,50 @@ object SubscriptionHelper { return UsageCheck(true, null) } + // ===== RESIDENCE SHARING ===== + + /** + * Check if user can share a residence (Pro feature). + * Returns true (blocked) when limitations are ON and user is not pro. + */ + fun canShareResidence(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(true, null) // Allow if no subscription data (fallback) + + if (!subscription.limitationsEnabled) { + return UsageCheck(true, null) // Limitations disabled, allow everything + } + + if (currentTier == "pro") { + return UsageCheck(true, null) // Pro tier can share + } + + // Free users cannot share residences + return UsageCheck(false, "share_residence") + } + + // ===== CONTRACTOR SHARING ===== + + /** + * Check if user can share a contractor (Pro feature). + * Returns true (blocked) when limitations are ON and user is not pro. + */ + fun canShareContractor(): UsageCheck { + val subscription = SubscriptionCache.currentSubscription.value + ?: return UsageCheck(true, null) // Allow if no subscription data (fallback) + + if (!subscription.limitationsEnabled) { + return UsageCheck(true, null) // Limitations disabled, allow everything + } + + if (currentTier == "pro") { + return UsageCheck(true, null) // Pro tier can share + } + + // Free users cannot share contractors + return UsageCheck(false, "share_contractor") + } + // ===== DEPRECATED - Keep for backwards compatibility ===== @Deprecated("Use isContractorsBlocked() instead", ReplaceWith("isContractorsBlocked()")) diff --git a/iosApp/Casera/AppIntent.swift b/iosApp/Casera/AppIntent.swift index ea47cad..8c128fd 100644 --- a/iosApp/Casera/AppIntent.swift +++ b/iosApp/Casera/AppIntent.swift @@ -43,6 +43,9 @@ struct CompleteTaskIntent: AppIntent { // Mark task as pending completion immediately (optimistic UI) WidgetActionManager.shared.markTaskPendingCompletion(taskId: taskId) + // Reload widget immediately to update task list and stats + WidgetCenter.shared.reloadTimelines(ofKind: "Casera") + // Get auth token and API URL from shared container guard let token = WidgetActionManager.shared.getAuthToken() else { print("CompleteTaskIntent: No auth token available") diff --git a/iosApp/Casera/MyCrib.swift b/iosApp/Casera/MyCrib.swift index 58f56b8..fa3f308 100644 --- a/iosApp/Casera/MyCrib.swift +++ b/iosApp/Casera/MyCrib.swift @@ -202,6 +202,52 @@ struct SimpleEntry: TimelineEntry { var nextTask: CacheManager.CustomTask? { upcomingTasks.first } + + /// Tasks due within the next 7 days + var dueThisWeekCount: Int { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let weekFromNow = calendar.date(byAdding: .day, value: 7, to: today)! + + return upcomingTasks.filter { task in + guard let dueDateString = task.dueDate else { return false } + guard let dueDate = parseDate(dueDateString) else { return false } + let dueDay = calendar.startOfDay(for: dueDate) + return dueDay <= weekFromNow + }.count + } + + /// Tasks due within the next 30 days + var dueNext30DaysCount: Int { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let thirtyDaysFromNow = calendar.date(byAdding: .day, value: 30, to: today)! + + return upcomingTasks.filter { task in + guard let dueDateString = task.dueDate else { return false } + guard let dueDate = parseDate(dueDateString) else { return false } + let dueDay = calendar.startOfDay(for: dueDate) + return dueDay <= thirtyDaysFromNow + }.count + } + + /// Parse date string to Date + private func parseDate(_ dateString: String) -> Date? { + let dateOnlyFormatter = DateFormatter() + dateOnlyFormatter.dateFormat = "yyyy-MM-dd" + if let date = dateOnlyFormatter.date(from: dateString) { + return date + } + + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = isoFormatter.date(from: dateString) { + return date + } + + isoFormatter.formatOptions = [.withInternetDateTime] + return isoFormatter.date(from: dateString) + } } struct CaseraEntryView : View { @@ -461,26 +507,12 @@ struct InteractiveTaskRowView: View { struct LargeWidgetView: View { let entry: SimpleEntry - private var maxTasksToShow: Int { - entry.upcomingTasks.count > 6 ? 5 : 6 - } + private var maxTasksToShow: Int { 5 } var body: some View { - VStack(alignment: .leading, spacing: 6) { - // Header - HStack(spacing: 6) { - Spacer() - - Text("\(entry.taskCount)") - .font(.system(size: 22, weight: .bold)) - .foregroundStyle(.blue) - - Text(entry.taskCount == 1 ? "task" : "tasks") - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(.secondary) - } - + VStack(alignment: .leading, spacing: 0) { if entry.upcomingTasks.isEmpty { + // Empty state - centered Spacer() VStack(spacing: 12) { @@ -494,25 +526,99 @@ struct LargeWidgetView: View { .frame(maxWidth: .infinity) Spacer() + + // Stats even when empty + LargeWidgetStatsView(entry: entry) } else { - // Show tasks with interactive buttons - ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in - LargeInteractiveTaskRowView(task: task) + // Tasks section - always at top + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(entry.upcomingTasks.prefix(maxTasksToShow).enumerated()), id: \.element.id) { index, task in + LargeInteractiveTaskRowView(task: task) + } + + if entry.upcomingTasks.count > maxTasksToShow { + Text("+ \(entry.upcomingTasks.count - maxTasksToShow) more") + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 2) + } } - if entry.upcomingTasks.count > 6 { - Text("+ \(entry.upcomingTasks.count - 5) more tasks") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 2) - } + Spacer(minLength: 12) + + // Stats section at bottom + LargeWidgetStatsView(entry: entry) } } .padding(14) } } +// MARK: - Large Widget Stats View +struct LargeWidgetStatsView: View { + let entry: SimpleEntry + + var body: some View { + HStack(spacing: 0) { + // Total Tasks + StatItem( + value: entry.taskCount, + label: "Total", + color: .blue + ) + + Divider() + .frame(height: 30) + + // Due This Week + StatItem( + value: entry.dueThisWeekCount, + label: "This Week", + color: .orange + ) + + Divider() + .frame(height: 30) + + // Due Next 30 Days + StatItem( + value: entry.dueNext30DaysCount, + label: "Next 30 Days", + color: .green + ) + } + .padding(.vertical, 10) + .padding(.horizontal, 8) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(Color.primary.opacity(0.05)) + ) + } +} + +// MARK: - Stat Item View +struct StatItem: View { + let value: Int + let label: String + let color: Color + + var body: some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(color) + + Text(label) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } +} + // MARK: - Large Interactive Task Row struct LargeInteractiveTaskRowView: View { let task: CacheManager.CustomTask @@ -530,7 +636,7 @@ struct LargeInteractiveTaskRowView: View { VStack(alignment: .leading, spacing: 2) { Text(task.title) .font(.system(size: 12, weight: .medium)) - .lineLimit(1) + .lineLimit(2) .foregroundStyle(.primary) HStack(spacing: 10) { diff --git a/iosApp/iosApp/Contractor/ContractorDetailView.swift b/iosApp/iosApp/Contractor/ContractorDetailView.swift index 9eba6ab..884f0e7 100644 --- a/iosApp/iosApp/Contractor/ContractorDetailView.swift +++ b/iosApp/iosApp/Contractor/ContractorDetailView.swift @@ -13,6 +13,8 @@ struct ContractorDetailView: View { @State private var showingDeleteAlert = false @State private var showingShareSheet = false @State private var shareFileURL: URL? + @State private var showingUpgradePrompt = false + @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared var body: some View { ZStack { @@ -27,7 +29,13 @@ struct ContractorDetailView: View { ToolbarItem(placement: .navigationBarTrailing) { if let contractor = viewModel.selectedContractor { Menu { - Button(action: { shareContractor(contractor) }) { + Button(action: { + if subscriptionCache.canShareContractor() { + shareContractor(contractor) + } else { + showingUpgradePrompt = true + } + }) { Label(L10n.Common.share, systemImage: "square.and.arrow.up") } @@ -61,6 +69,9 @@ struct ContractorDetailView: View { ShareSheet(activityItems: [url]) } } + .sheet(isPresented: $showingUpgradePrompt) { + UpgradePromptView(triggerKey: "share_contractor", isPresented: $showingUpgradePrompt) + } .sheet(isPresented: $showingEditSheet) { ContractorFormSheet( contractor: viewModel.selectedContractor, diff --git a/iosApp/iosApp/Helpers/L10n.swift b/iosApp/iosApp/Helpers/L10n.swift index 9c2ddda..68d07be 100644 --- a/iosApp/iosApp/Helpers/L10n.swift +++ b/iosApp/iosApp/Helpers/L10n.swift @@ -561,6 +561,15 @@ enum L10n { static var support: String { String(localized: "profile_support") } static var contactSupport: String { String(localized: "profile_contact_support") } static var contactSupportSubtitle: String { String(localized: "profile_contact_support_subtitle") } + + // Upgrade Benefits + static var unlockPremiumFeatures: String { String(localized: "profile_unlock_premium_features") } + static var benefitUnlimitedProperties: String { String(localized: "profile_benefit_unlimited_properties") } + static var benefitDocumentVault: String { String(localized: "profile_benefit_document_vault") } + static var benefitResidenceSharing: String { String(localized: "profile_benefit_residence_sharing") } + static var benefitContractorSharing: String { String(localized: "profile_benefit_contractor_sharing") } + static var benefitActionableNotifications: String { String(localized: "profile_benefit_actionable_notifications") } + static var benefitWidgets: String { String(localized: "profile_benefit_widgets") } } // MARK: - Settings diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index 990a584..88d2dfc 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -19557,6 +19557,83 @@ } } }, + "profile_unlock_premium_features" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlock Premium Features" + } + } + } + }, + "profile_benefit_unlimited_properties" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited Properties" + } + } + } + }, + "profile_benefit_document_vault" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Document & Warranty Storage" + } + } + } + }, + "profile_benefit_residence_sharing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Residence Sharing" + } + } + } + }, + "profile_benefit_contractor_sharing" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contractor Sharing" + } + } + } + }, + "profile_benefit_actionable_notifications" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actionable Notifications" + } + } + } + }, + "profile_benefit_widgets" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home Screen Widgets" + } + } + } + }, "profile_support" : { "extractionState" : "manual", "localizations" : { diff --git a/iosApp/iosApp/MainTabView.swift b/iosApp/iosApp/MainTabView.swift index 9e4531e..d5d095e 100644 --- a/iosApp/iosApp/MainTabView.swift +++ b/iosApp/iosApp/MainTabView.swift @@ -4,6 +4,7 @@ struct MainTabView: View { @EnvironmentObject private var themeManager: ThemeManager @State private var selectedTab = 0 @StateObject private var authManager = AuthenticationManager.shared + @ObservedObject private var pushManager = PushNotificationManager.shared var refreshID: UUID var body: some View { @@ -52,6 +53,13 @@ struct MainTabView: View { .onChange(of: authManager.isAuthenticated) { _ in selectedTab = 0 } + // Check for pending navigation when view appears (app launched from notification) + .onAppear { + if pushManager.pendingNavigationTaskId != nil { + selectedTab = 1 // Switch to Tasks tab + // Note: Don't clear here - AllTasksView will handle navigation and clear it + } + } // Handle push notification deep links - switch to appropriate tab // The actual task navigation is handled by AllTasksView .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { _ in diff --git a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift index 22815f0..40354a6 100644 --- a/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingSubscriptionView.swift @@ -16,12 +16,6 @@ struct OnboardingSubscriptionContent: View { description: "Track every home you own—vacation houses, rentals, you name it", gradient: [Color.appPrimary, Color.appSecondary] ), - SubscriptionBenefit( - icon: "bell.badge.fill", - title: "Smart Reminders", - description: "Never miss a maintenance deadline. Ever.", - gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange] - ), SubscriptionBenefit( icon: "doc.badge.plus", title: "Document Vault", @@ -35,10 +29,22 @@ struct OnboardingSubscriptionContent: View { gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple] ), SubscriptionBenefit( - icon: "chart.line.uptrend.xyaxis", - title: "Spending Insights", - description: "See where your money goes and plan smarter", + icon: "square.and.arrow.up.fill", + title: "Contractor Sharing", + description: "Share your trusted contractors with family and friends", + gradient: [Color.appAccent, Color(hex: "#FF9500") ?? .orange] + ), + SubscriptionBenefit( + icon: "bell.badge.fill", + title: "Actionable Notifications", + description: "Complete tasks directly from notifications—no app opening needed", gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red] + ), + SubscriptionBenefit( + icon: "square.grid.2x2.fill", + title: "Home Screen Widgets", + description: "Quick actions right from your home screen", + gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue] ) ] diff --git a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift index 76b22cb..9f000ec 100644 --- a/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift +++ b/iosApp/iosApp/Onboarding/OnboardingValuePropsView.swift @@ -17,15 +17,6 @@ struct OnboardingValuePropsContent: View { statNumber: "$6,000+", statLabel: "spent yearly on repairs that could've been prevented" ), - FeatureHighlight( - icon: "dollarsign.circle.fill", - title: "Save Thousands", - subtitle: "Prevention beats repair every time", - description: "Delayed maintenance turns $100 fixes into $10,000 disasters. Stay ahead of problems before they drain your wallet.", - gradient: [Color(hex: "#34C759") ?? .green, Color(hex: "#30D158") ?? .green], - statNumber: "46%", - statLabel: "of homeowners spent $5,000+ on surprise repairs" - ), FeatureHighlight( icon: "doc.text.fill", title: "Warranties at Your Fingertips", @@ -43,6 +34,24 @@ struct OnboardingValuePropsContent: View { gradient: [Color(hex: "#AF52DE") ?? .purple, Color(hex: "#BF5AF2") ?? .purple], statNumber: "56%", statLabel: "say sharing chores is key to a happy home" + ), + FeatureHighlight( + icon: "bell.badge.fill", + title: "Smart Notifications", + subtitle: "Act right from your lock screen", + description: "Get actionable reminders that let you complete tasks right from the notification. No need to even open the app.", + gradient: [Color(hex: "#FF3B30") ?? .red, Color(hex: "#FF6961") ?? .red], + statNumber: "3x", + statLabel: "faster task completion with actionable notifications" + ), + FeatureHighlight( + icon: "square.grid.2x2.fill", + title: "Home Screen Widgets", + subtitle: "Your tasks at a glance", + description: "Quick access to upcoming tasks and reminders directly from your home screen. Stay on top of maintenance without opening the app.", + gradient: [Color(hex: "#007AFF") ?? .blue, Color(hex: "#5AC8FA") ?? .blue], + statNumber: "68%", + statLabel: "of widget users complete tasks on time" ) ] diff --git a/iosApp/iosApp/Profile/ProfileTabView.swift b/iosApp/iosApp/Profile/ProfileTabView.swift index c7b5243..10983c4 100644 --- a/iosApp/iosApp/Profile/ProfileTabView.swift +++ b/iosApp/iosApp/Profile/ProfileTabView.swift @@ -89,6 +89,23 @@ struct ProfileTabView: View { .padding(.vertical, 4) if subscriptionCache.currentTier != "pro" { + // Upgrade Benefits List + VStack(alignment: .leading, spacing: 8) { + Text(L10n.Profile.unlockPremiumFeatures) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color.appPrimary) + .padding(.top, 4) + + UpgradeBenefitRow(icon: "building.2.fill", text: L10n.Profile.benefitUnlimitedProperties) + UpgradeBenefitRow(icon: "doc.fill", text: L10n.Profile.benefitDocumentVault) + UpgradeBenefitRow(icon: "person.2.fill", text: L10n.Profile.benefitResidenceSharing) + UpgradeBenefitRow(icon: "square.and.arrow.up.fill", text: L10n.Profile.benefitContractorSharing) + UpgradeBenefitRow(icon: "bell.badge.fill", text: L10n.Profile.benefitActionableNotifications) + UpgradeBenefitRow(icon: "square.grid.2x2.fill", text: L10n.Profile.benefitWidgets) + } + .padding(.vertical, 4) + Button(action: { showUpgradePrompt = true }) { Label(L10n.Profile.upgradeToPro, systemImage: "arrow.up.circle.fill") .foregroundColor(Color.appPrimary) @@ -223,3 +240,23 @@ struct ProfileTabView: View { } } } + +// MARK: - Upgrade Benefit Row + +private struct UpgradeBenefitRow: View { + let icon: String + let text: String + + var body: some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.caption) + .foregroundColor(Color.appPrimary) + .frame(width: 18) + + Text(text) + .font(.caption) + .foregroundColor(Color.appTextSecondary) + } + } +} diff --git a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift index d168924..86698af 100644 --- a/iosApp/iosApp/PushNotifications/PushNotificationManager.swift +++ b/iosApp/iosApp/PushNotifications/PushNotificationManager.swift @@ -9,6 +9,9 @@ class PushNotificationManager: NSObject, ObservableObject { @Published var deviceToken: String? @Published var notificationPermissionGranted = false + /// Pending task ID to navigate to (set when notification is tapped before app is ready) + @Published var pendingNavigationTaskId: Int? + private let registeredTokenKey = "com.casera.registeredDeviceToken" /// The last token that was successfully registered with the backend @@ -175,18 +178,23 @@ class PushNotificationManager: NSObject, ObservableObject { } // Check subscription status to determine navigation - let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" || - !(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true) + // Allow navigation if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet + let subscription = SubscriptionCacheWrapper.shared.currentSubscription + let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" + let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded + let canNavigateToTask = isPremium || !limitationsEnabled - if isPremium { - // Premium user - navigate to task detail + print("📬 Push nav check: isPremium=\(isPremium), limitationsEnabled=\(limitationsEnabled), canNavigate=\(canNavigateToTask), subscription=\(subscription != nil ? "loaded" : "nil")") + + if canNavigateToTask { + // Navigate to task detail if let taskIdStr = userInfo["task_id"] as? String, let taskId = Int(taskIdStr) { navigateToTask(taskId: taskId) } else if let taskId = userInfo["task_id"] as? Int { navigateToTask(taskId: taskId) } } else { - // Free user - navigate to home + // Free user with limitations enabled - navigate to home navigateToHome() } } @@ -203,11 +211,14 @@ class PushNotificationManager: NSObject, ObservableObject { } // Check subscription status - let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" || - !(SubscriptionCacheWrapper.shared.currentSubscription?.limitationsEnabled ?? true) + // Allow actions if: Pro user, OR limitations are disabled, OR subscription hasn't loaded yet + let subscription = SubscriptionCacheWrapper.shared.currentSubscription + let isPremium = SubscriptionCacheWrapper.shared.currentTier == "pro" + let limitationsEnabled = subscription?.limitationsEnabled ?? false // Default to false (allow) if not loaded + let canPerformAction = isPremium || !limitationsEnabled - guard isPremium else { - // Free user shouldn't see actions, but if they somehow do, go to home + guard canPerformAction else { + // Free user with limitations enabled shouldn't see actions, but if they somehow do, go to home navigateToHome() return } @@ -375,6 +386,8 @@ class PushNotificationManager: NSObject, ObservableObject { private func navigateToTask(taskId: Int) { print("📱 Navigating to task \(taskId)") + // Store pending navigation in case MainTabView isn't ready yet + pendingNavigationTaskId = taskId NotificationCenter.default.post( name: .navigateToTask, object: nil, @@ -382,6 +395,11 @@ class PushNotificationManager: NSObject, ObservableObject { ) } + /// Clear pending navigation after it has been handled + func clearPendingNavigation() { + pendingNavigationTaskId = nil + } + private func navigateToEditTask(taskId: Int) { print("✏️ Navigating to edit task \(taskId)") NotificationCenter.default.post( diff --git a/iosApp/iosApp/Residence/ResidenceDetailView.swift b/iosApp/iosApp/Residence/ResidenceDetailView.swift index 4e25f88..3e5bc16 100644 --- a/iosApp/iosApp/Residence/ResidenceDetailView.swift +++ b/iosApp/iosApp/Residence/ResidenceDetailView.swift @@ -32,6 +32,7 @@ struct ResidenceDetailView: View { @State private var showDeleteConfirmation = false @State private var isDeleting = false @State private var showingUpgradePrompt = false + @State private var upgradeTriggerKey = "" @State private var showShareSheet = false @State private var shareFileURL: URL? @StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared @@ -147,7 +148,7 @@ struct ResidenceDetailView: View { } } .sheet(isPresented: $showingUpgradePrompt) { - UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt) + UpgradePromptView(triggerKey: upgradeTriggerKey.isEmpty ? "add_11th_task" : upgradeTriggerKey, isPresented: $showingUpgradePrompt) } .sheet(isPresented: $showShareSheet) { if let url = shareFileURL { @@ -359,7 +360,12 @@ private extension ResidenceDetailView { // Share Residence button (owner only) if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { Button { - shareResidence(residence) + if subscriptionCache.canShareResidence() { + shareResidence(residence) + } else { + upgradeTriggerKey = "share_residence" + showingUpgradePrompt = true + } } label: { if sharingManager.isGeneratingPackage { ProgressView() @@ -370,10 +376,25 @@ private extension ResidenceDetailView { .disabled(sharingManager.isGeneratingPackage) } + // Manage Users button (owner only) + if let residence = viewModel.selectedResidence, isCurrentUserOwner(of: residence) { + Button { + if subscriptionCache.canShareResidence() { + showManageUsers = true + } else { + upgradeTriggerKey = "share_residence" + showingUpgradePrompt = true + } + } label: { + Image(systemName: "person.2") + } + } + Button { // Check LIVE task count before adding let totalTasks = tasksResponse?.columns.reduce(0) { $0 + $1.tasks.count } ?? 0 if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTasks, limitKey: "tasks") { + upgradeTriggerKey = "add_11th_task" showingUpgradePrompt = true } else { showAddTask = true diff --git a/iosApp/iosApp/Subscription/SubscriptionCache.swift b/iosApp/iosApp/Subscription/SubscriptionCache.swift index 386e701..82dd0f9 100644 --- a/iosApp/iosApp/Subscription/SubscriptionCache.swift +++ b/iosApp/iosApp/Subscription/SubscriptionCache.swift @@ -68,6 +68,28 @@ class SubscriptionCacheWrapper: ObservableObject { currentTier == "free" && (currentSubscription?.limitationsEnabled ?? false) } + /// Check if user can share residences (Pro feature) + /// - Returns: true if allowed, false if should show upgrade prompt + func canShareResidence() -> Bool { + // If limitations are disabled globally, allow + guard let subscription = currentSubscription, subscription.limitationsEnabled else { + return true + } + // Pro tier can share + return currentTier == "pro" + } + + /// Check if user can share contractors (Pro feature) + /// - Returns: true if allowed, false if should show upgrade prompt + func canShareContractor() -> Bool { + // If limitations are disabled globally, allow + guard let subscription = currentSubscription, subscription.limitationsEnabled else { + return true + } + // Pro tier can share + return currentTier == "pro" + } + private init() { // Start observation of Kotlin cache Task { @MainActor in diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index d1bd942..89036f6 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -20,6 +20,8 @@ struct AllTasksView: View { // Deep link task ID to open (from push notification) @State private var pendingTaskId: Int32? + // Column index to scroll to (for deep link navigation) + @State private var scrollToColumnIndex: Int? // Use ViewModel's computed properties private var totalTaskCount: Int { taskViewModel.totalTaskCount } @@ -100,6 +102,11 @@ struct AllTasksView: View { .onAppear { PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown) + // Check for pending navigation from push notification (app launched from notification) + if let taskId = PushNotificationManager.shared.pendingNavigationTaskId { + pendingTaskId = Int32(taskId) + } + // Check if widget completed a task - force refresh if dirty if WidgetDataManager.shared.areTasksDirty() { WidgetDataManager.shared.clearDirtyFlag() @@ -111,27 +118,38 @@ struct AllTasksView: View { } // Handle push notification deep links .onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in + print("📬 AllTasksView received .navigateToTask notification") if let userInfo = notification.userInfo, let taskId = userInfo["taskId"] as? Int { + print("📬 Setting pendingTaskId to \(taskId)") pendingTaskId = Int32(taskId) + // If tasks are already loaded, try to navigate immediately + if let response = tasksResponse { + print("📬 Tasks already loaded, attempting immediate navigation") + navigateToTaskInKanban(taskId: Int32(taskId), response: response) + } + } else { + print("📬 Failed to extract taskId from notification userInfo: \(notification.userInfo ?? [:])") } } .onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in + print("📬 AllTasksView received .navigateToEditTask notification") if let userInfo = notification.userInfo, let taskId = userInfo["taskId"] as? Int { + print("📬 Setting pendingTaskId to \(taskId)") pendingTaskId = Int32(taskId) + // If tasks are already loaded, try to navigate immediately + if let response = tasksResponse { + print("📬 Tasks already loaded, attempting immediate navigation") + navigateToTaskInKanban(taskId: Int32(taskId), response: response) + } } } - // When tasks load and we have a pending task ID, open the edit sheet + // When tasks load and we have a pending task ID, scroll to column and open the edit sheet .onChange(of: tasksResponse) { response in + print("📬 tasksResponse changed, pendingTaskId=\(pendingTaskId?.description ?? "nil")") if let taskId = pendingTaskId, let response = response { - // Find the task in all columns - let allTasks = response.columns.flatMap { $0.tasks } - if let task = allTasks.first(where: { $0.id == taskId }) { - selectedTaskForEdit = task - showEditTask = true - pendingTaskId = nil - } + navigateToTaskInKanban(taskId: taskId, response: response) } } // Check dirty flag when app returns from background (widget may have completed a task) @@ -209,59 +227,73 @@ struct AllTasksView: View { } .padding() } else { - ScrollView(.horizontal, showsIndicators: false) { - LazyHGrid(rows: [ - GridItem(.flexible(), spacing: 16) - ], spacing: 16) { - // Dynamically create columns from response - ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in - DynamicTaskColumnView( - column: column, - onEditTask: { task in - selectedTaskForEdit = task - showEditTask = true - }, - onCancelTask: { task in - selectedTaskForCancel = task - showCancelConfirmation = true - }, - onUncancelTask: { taskId in - taskViewModel.uncancelTask(id: taskId) { _ in - loadAllTasks() - } - }, - onMarkInProgress: { taskId in - taskViewModel.markInProgress(id: taskId) { success in - if success { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + LazyHGrid(rows: [ + GridItem(.flexible(), spacing: 16) + ], spacing: 16) { + // Dynamically create columns from response + ForEach(Array(tasksResponse.columns.enumerated()), id: \.element.name) { index, column in + DynamicTaskColumnView( + column: column, + onEditTask: { task in + selectedTaskForEdit = task + showEditTask = true + }, + onCancelTask: { task in + selectedTaskForCancel = task + showCancelConfirmation = true + }, + onUncancelTask: { taskId in + taskViewModel.uncancelTask(id: taskId) { _ in + loadAllTasks() + } + }, + onMarkInProgress: { taskId in + taskViewModel.markInProgress(id: taskId) { success in + if success { + loadAllTasks() + } + } + }, + onCompleteTask: { task in + selectedTaskForComplete = task + }, + onArchiveTask: { task in + selectedTaskForArchive = task + showArchiveConfirmation = true + }, + onUnarchiveTask: { taskId in + taskViewModel.unarchiveTask(id: taskId) { _ in loadAllTasks() } } - }, - onCompleteTask: { task in - selectedTaskForComplete = task - }, - onArchiveTask: { task in - selectedTaskForArchive = task - showArchiveConfirmation = true - }, - onUnarchiveTask: { taskId in - taskViewModel.unarchiveTask(id: taskId) { _ in - loadAllTasks() - } + ) + .frame(width: 350) + .id(index) // Add ID for ScrollViewReader + .scrollTransition { content, phase in + content + .opacity(phase.isIdentity ? 1 : 0.8) + .scaleEffect(phase.isIdentity ? 1 : 0.95) } - ) - .frame(width: 350) - .scrollTransition { content, phase in - content - .opacity(phase.isIdentity ? 1 : 0.8) - .scaleEffect(phase.isIdentity ? 1 : 0.95) + } + } + .scrollTargetLayout() + .padding(16) + } + .scrollTargetBehavior(.viewAligned) + .onChange(of: scrollToColumnIndex) { columnIndex in + if let columnIndex = columnIndex { + withAnimation(.easeInOut(duration: 0.3)) { + proxy.scrollTo(columnIndex, anchor: .leading) + } + // Clear after scrolling + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + scrollToColumnIndex = nil } } } - .scrollTargetLayout() - .padding(16) } - .scrollTargetBehavior(.viewAligned) } } } @@ -310,6 +342,32 @@ struct AllTasksView: View { private func updateTaskInKanban(_ updatedTask: TaskResponse) { taskViewModel.updateTaskInKanban(updatedTask) } + + private func navigateToTaskInKanban(taskId: Int32, response: TaskColumnsResponse) { + print("📬 navigateToTaskInKanban called with taskId=\(taskId)") + + // Find which column contains the task + for (index, column) in response.columns.enumerated() { + if column.tasks.contains(where: { $0.id == taskId }) { + print("📬 Found task in column \(index) '\(column.name)'") + + // Clear pending + pendingTaskId = nil + PushNotificationManager.shared.clearPendingNavigation() + + // Small delay to ensure view is ready, then scroll + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.scrollToColumnIndex = index + } + return + } + } + + // Task not found + print("📬 Task with id=\(taskId) not found") + pendingTaskId = nil + PushNotificationManager.shared.clearPendingNavigation() + } } // Extension to apply corner radius to specific corners diff --git a/iosApp/test_push.apns b/iosApp/test_push.apns new file mode 100644 index 0000000..e25c7b6 --- /dev/null +++ b/iosApp/test_push.apns @@ -0,0 +1,16 @@ +{ + "Simulator Target Bundle": "com.tt.casera.CaseraDev", + "aps": { + "alert": { + "title": "Task Reminder", + "subtitle": "123 Main Street", + "body": "Change HVAC filter is due today" + }, + "badge": 1, + "sound": "default", + "category": "TASK_REMINDER" + }, + "task_id": "54", + "type": "task_due_soon", + "notification_id": "123" +}