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:
Trey t
2025-12-10 23:17:28 -06:00
parent ed14a1c69e
commit cbe073aa21
21 changed files with 723 additions and 127 deletions

View File

@@ -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) {