Add push notification deep linking and sharing subscription checks
- Add deep link navigation from push notifications to specific task column on kanban board - Fix subscription check in push notification handler to allow navigation when limitations disabled - Add pendingNavigationTaskId to handle notifications when app isn't ready - Add ScrollViewReader to AllTasksView for programmatic scrolling to task column - Add canShareResidence() and canShareContractor() subscription checks (iOS & Android) - Add test APNS file for simulator push notification testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,6 +72,8 @@
|
||||
<string name="properties_join_title">Join Property</string>
|
||||
<string name="properties_join_code_label">Enter Share Code</string>
|
||||
<string name="properties_join_button">Join</string>
|
||||
<string name="properties_share_upgrade_title">Pro Feature</string>
|
||||
<string name="properties_share_upgrade_message">Sharing residences is a Pro feature. Upgrade to invite family members to collaborate on home maintenance.</string>
|
||||
<string name="properties_users_title">Property Members</string>
|
||||
<string name="properties_owner">Owner</string>
|
||||
<string name="properties_member">Member</string>
|
||||
@@ -248,6 +250,8 @@
|
||||
<string name="contractors_delete_warning">Are you sure you want to delete this contractor? This action cannot be undone.</string>
|
||||
<string name="contractors_completed_tasks">%1$d completed tasks</string>
|
||||
<string name="contractors_share">Share Contractor</string>
|
||||
<string name="contractors_share_upgrade_title">Pro Feature</string>
|
||||
<string name="contractors_share_upgrade_message">Sharing contractors is a Pro feature. Upgrade to share your trusted contractors with friends and family.</string>
|
||||
<string name="contractors_import_title">Import Contractor</string>
|
||||
<string name="contractors_import_message">Would you like to import this contractor?</string>
|
||||
<string name="contractors_import_success">Contractor Imported</string>
|
||||
@@ -368,6 +372,14 @@
|
||||
<string name="profile_support">Support</string>
|
||||
<string name="profile_contact_support">Contact Support</string>
|
||||
<string name="profile_contact_support_subtitle">Get help with your account</string>
|
||||
<string name="profile_upgrade_benefits_title">Unlock Premium Features</string>
|
||||
<string name="profile_upgrade_benefits_subtitle">Upgrade to Pro for the complete experience</string>
|
||||
<string name="profile_benefit_unlimited_properties">Unlimited Properties</string>
|
||||
<string name="profile_benefit_document_vault">Document & Warranty Storage</string>
|
||||
<string name="profile_benefit_residence_sharing">Residence Sharing</string>
|
||||
<string name="profile_benefit_contractor_sharing">Contractor Sharing</string>
|
||||
<string name="profile_benefit_actionable_notifications">Actionable Notifications</string>
|
||||
<string name="profile_benefit_widgets">Home Screen Widgets</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_title">Settings</string>
|
||||
@@ -516,6 +528,10 @@
|
||||
<string name="onboarding_feature_contractors_desc">Keep all your contractor contacts organized and easily accessible</string>
|
||||
<string name="onboarding_feature_family_title">Share With Family</string>
|
||||
<string name="onboarding_feature_family_desc">Invite family members to collaborate on home maintenance together</string>
|
||||
<string name="onboarding_feature_notifications_title">Smart Notifications</string>
|
||||
<string name="onboarding_feature_notifications_desc">Get actionable reminders that let you complete tasks right from the notification</string>
|
||||
<string name="onboarding_feature_widgets_title">Home Screen Widgets</string>
|
||||
<string name="onboarding_feature_widgets_desc">Quick access to tasks and reminders directly from your home screen</string>
|
||||
|
||||
<!-- Onboarding - Name Residence -->
|
||||
<string name="onboarding_name_residence_title">Name Your Home</string>
|
||||
@@ -570,6 +586,12 @@
|
||||
<string name="onboarding_subscription_benefit_family_desc">Get everyone on the same page</string>
|
||||
<string name="onboarding_subscription_benefit_insights">Spending Insights</string>
|
||||
<string name="onboarding_subscription_benefit_insights_desc">See where your money goes</string>
|
||||
<string name="onboarding_subscription_benefit_contractor_sharing">Contractor Sharing</string>
|
||||
<string name="onboarding_subscription_benefit_contractor_sharing_desc">Share your trusted contractors with family and friends</string>
|
||||
<string name="onboarding_subscription_benefit_widgets">Home Screen Widgets</string>
|
||||
<string name="onboarding_subscription_benefit_widgets_desc">Quick actions right from your home screen</string>
|
||||
<string name="onboarding_subscription_benefit_notifications">Actionable Notifications</string>
|
||||
<string name="onboarding_subscription_benefit_notifications_desc">Complete tasks directly from notifications</string>
|
||||
<string name="onboarding_subscription_choose_plan">Choose your plan</string>
|
||||
<string name="onboarding_subscription_monthly">Monthly</string>
|
||||
<string name="onboarding_subscription_yearly">Yearly</string>
|
||||
|
||||
@@ -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<String?>(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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
16
iosApp/test_push.apns
Normal file
16
iosApp/test_push.apns
Normal file
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user