Files
honeyDueKMP/iosApp/iosApp/Task/AllTasksView.swift
Trey t efdb760438 Add interactive iOS widget with subscription-based views
- Add direct API completion from widget via quick-complete endpoint
- Share auth token and API URL with widget via App Group UserDefaults
- Add dirty flag mechanism to refresh tasks when app returns from background
- Widget checkbox colors indicate priority (red=urgent, orange=high, yellow=medium, green=low)
- Show simple "X tasks waiting" view for free tier users when limitations enabled
- Show interactive task completion widget for premium users or when limitations disabled
- Sync subscription status with widget extension for view selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-08 12:02:16 -06:00

354 lines
15 KiB
Swift

import SwiftUI
import ComposeApp
struct AllTasksView: View {
@Environment(\.scenePhase) private var scenePhase
@StateObject private var taskViewModel = TaskViewModel()
@StateObject private var residenceViewModel = ResidenceViewModel()
@StateObject private var subscriptionCache = SubscriptionCacheWrapper.shared
@State private var showAddTask = false
@State private var showEditTask = false
@State private var showingUpgradePrompt = false
@State private var selectedTaskForEdit: TaskResponse?
@State private var selectedTaskForComplete: TaskResponse?
@State private var selectedTaskForArchive: TaskResponse?
@State private var showArchiveConfirmation = false
@State private var selectedTaskForCancel: TaskResponse?
@State private var showCancelConfirmation = false
// Deep link task ID to open (from push notification)
@State private var pendingTaskId: Int32?
// Use ViewModel's computed properties
private var totalTaskCount: Int { taskViewModel.totalTaskCount }
private var hasNoTasks: Bool { taskViewModel.hasNoTasks }
private var hasTasks: Bool { taskViewModel.hasTasks }
private var tasksResponse: TaskColumnsResponse? { taskViewModel.tasksResponse }
private var isLoadingTasks: Bool { taskViewModel.isLoadingTasks }
private var tasksError: String? { taskViewModel.tasksError }
var body: some View {
mainContent
.sheet(isPresented: $showAddTask) {
AddTaskWithResidenceView(
isPresented: $showAddTask,
residences: residenceViewModel.myResidences?.residences.toResidences() ?? []
)
}
.sheet(isPresented: $showEditTask) {
if let task = selectedTaskForEdit {
EditTaskView(task: task, isPresented: $showEditTask)
}
}
.sheet(item: $selectedTaskForComplete) { task in
CompleteTaskView(task: task) { updatedTask in
if let updatedTask = updatedTask {
updateTaskInKanban(updatedTask)
}
selectedTaskForComplete = nil
}
}
.sheet(isPresented: $showingUpgradePrompt) {
UpgradePromptView(triggerKey: "add_11th_task", isPresented: $showingUpgradePrompt)
}
.alert(L10n.Tasks.archiveTask, isPresented: $showArchiveConfirmation) {
Button(L10n.Common.cancel, role: .cancel) {
selectedTaskForArchive = nil
}
Button(L10n.Tasks.archive, role: .destructive) {
if let task = selectedTaskForArchive {
taskViewModel.archiveTask(id: task.id) { _ in
loadAllTasks()
}
selectedTaskForArchive = nil
}
}
} message: {
if let task = selectedTaskForArchive {
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\""))
}
}
.alert(L10n.Tasks.deleteTask, isPresented: $showCancelConfirmation) {
Button(L10n.Common.cancel, role: .cancel) {
selectedTaskForCancel = nil
}
Button(L10n.Tasks.archive, role: .destructive) {
if let task = selectedTaskForCancel {
taskViewModel.cancelTask(id: task.id) { _ in
loadAllTasks()
}
selectedTaskForCancel = nil
}
}
} message: {
if let task = selectedTaskForCancel {
Text(L10n.Tasks.archiveConfirm.replacingOccurrences(of: "this task", with: "\"\(task.title)\""))
}
}
.onChange(of: showAddTask) { isShowing in
if !isShowing {
loadAllTasks()
}
}
.onChange(of: showEditTask) { isShowing in
if !isShowing {
loadAllTasks()
}
}
.onAppear {
PostHogAnalytics.shared.screen(AnalyticsEvents.taskScreenShown)
// Check if widget completed a task - force refresh if dirty
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
loadAllTasks(forceRefresh: true)
} else {
loadAllTasks()
}
residenceViewModel.loadMyResidences()
}
// Handle push notification deep links
.onReceive(NotificationCenter.default.publisher(for: .navigateToTask)) { notification in
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
pendingTaskId = Int32(taskId)
}
}
.onReceive(NotificationCenter.default.publisher(for: .navigateToEditTask)) { notification in
if let userInfo = notification.userInfo,
let taskId = userInfo["taskId"] as? Int {
pendingTaskId = Int32(taskId)
}
}
// When tasks load and we have a pending task ID, open the edit sheet
.onChange(of: tasksResponse) { response in
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
}
}
}
// Check dirty flag when app returns from background (widget may have completed a task)
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
if WidgetDataManager.shared.areTasksDirty() {
WidgetDataManager.shared.clearDirtyFlag()
loadAllTasks(forceRefresh: true)
}
}
}
}
@ViewBuilder
private var mainContent: some View {
ZStack {
Color.appBackgroundPrimary
.ignoresSafeArea()
if hasNoTasks && isLoadingTasks {
ProgressView()
} else if let error = tasksError {
ErrorView(message: error) {
loadAllTasks()
}
} else if let tasksResponse = tasksResponse {
if hasNoTasks {
// Empty state with big button
VStack(spacing: 24) {
Spacer()
Image(systemName: "checklist")
.font(.system(size: 64))
.foregroundStyle(Color.appPrimary.opacity(0.6))
Text(L10n.Tasks.noTasksYet)
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(Color.appTextPrimary)
Text(L10n.Tasks.createFirst)
.font(.body)
.foregroundColor(Color.appTextSecondary)
.multilineTextAlignment(.center)
Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
HStack(spacing: 8) {
Image(systemName: "plus")
Text(L10n.Tasks.addButton)
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.horizontal, 48)
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
if residenceViewModel.myResidences?.residences.isEmpty ?? true {
Text(L10n.Tasks.addPropertyFirst)
.font(.caption)
.foregroundColor(Color.appError)
}
Spacer()
}
.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: { taskId in
let allTasks = tasksResponse.columns.flatMap { $0.tasks }
if let task = allTasks.first(where: { $0.id == taskId }) {
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: { taskId in
let allTasks = tasksResponse.columns.flatMap { $0.tasks }
if let task = allTasks.first(where: { $0.id == taskId }) {
selectedTaskForArchive = task
showArchiveConfirmation = true
}
},
onUnarchiveTask: { taskId in
taskViewModel.unarchiveTask(id: taskId) { _ in
loadAllTasks()
}
}
)
.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)
}
}
}
.scrollContentBackground(.hidden)
.background(Color.appBackgroundPrimary)
.navigationTitle(L10n.Tasks.allTasks)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
// Check if we should show upgrade prompt before adding
if subscriptionCache.shouldShowUpgradePrompt(currentCount: totalTaskCount, limitKey: "tasks") {
showingUpgradePrompt = true
} else {
showAddTask = true
}
}) {
Image(systemName: "plus")
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true)
.accessibilityIdentifier(AccessibilityIdentifiers.Task.addButton)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
loadAllTasks(forceRefresh: true)
}) {
Image(systemName: "arrow.clockwise")
.rotationEffect(.degrees(isLoadingTasks ? 360 : 0))
.animation(isLoadingTasks ? .linear(duration: 0.5).repeatForever(autoreverses: false) : .default, value: isLoadingTasks)
}
.disabled(residenceViewModel.myResidences?.residences.isEmpty ?? true || isLoadingTasks)
}
}
.onChange(of: taskViewModel.isLoading) { isLoading in
if !isLoading {
loadAllTasks()
}
}
}
private func loadAllTasks(forceRefresh: Bool = false) {
taskViewModel.loadTasks(forceRefresh: forceRefresh)
}
private func updateTaskInKanban(_ updatedTask: TaskResponse) {
taskViewModel.updateTaskInKanban(updatedTask)
}
}
// Extension to apply corner radius to specific corners
extension View {
func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View {
clipShape(RoundedCorner(radius: radius, corners: corners))
}
}
struct RoundedCorner: Shape {
var radius: CGFloat = .infinity
var corners: UIRectCorner = .allCorners
func path(in rect: CGRect) -> Path {
let path = UIBezierPath(
roundedRect: rect,
byRoundingCorners: corners,
cornerRadii: CGSize(width: radius, height: radius)
)
return Path(path.cgPath)
}
}
#Preview {
NavigationView {
AllTasksView()
}
}
extension Array where Element == ResidenceResponse {
/// Returns the array as-is (for API compatibility)
func toResidences() -> [ResidenceResponse] {
return self
}
}