- 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>
354 lines
15 KiB
Swift
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
|
|
}
|
|
}
|