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"
+}