Add task completion animations and fix 7-day task count
Animation Testing: - Add AnimationTesting module with 14 animation types for task completion - Include 4 celebration animations: Implode, Firework, Starburst, Ripple - Card shrinks, shows checkmark with effect, then moves to next column - Extended timing (2.2s) for celebration animations Task Count Fix: - Fix "7 Days" and "30 Days" counts on residence cards and dashboard - Previously used API column membership (30-day "due soon" column) - Now calculates actual days until due from task's effectiveDueDate - Correctly counts tasks due within 7 days vs 8-30 days 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,9 +8,13 @@
|
|||||||
<string>upload</string>
|
<string>upload</string>
|
||||||
<key>signingStyle</key>
|
<key>signingStyle</key>
|
||||||
<string>automatic</string>
|
<string>automatic</string>
|
||||||
|
<key>signingCertificate</key>
|
||||||
|
<string>Apple Distribution</string>
|
||||||
<key>uploadSymbols</key>
|
<key>uploadSymbols</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>manageAppVersionAndBuildNumber</key>
|
<key>manageAppVersionAndBuildNumber</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>teamID</key>
|
||||||
|
<string>V3PF3M6B6U</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -513,9 +513,29 @@ class DataManagerObservable: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convert TaskResponse array to WidgetTask array for shared calculator
|
/// Convert TaskResponse array to WidgetTask array for shared calculator
|
||||||
|
/// isDueWithin7Days and isDue8To30Days are calculated from actual due dates,
|
||||||
|
/// NOT from API column membership (which uses different thresholds)
|
||||||
private func toWidgetTasks(_ tasks: [TaskResponse], overdueIds: Set<Int32>, dueWithin7DaysIds: Set<Int32>, due8To30DaysIds: Set<Int32>) -> [WidgetDataManager.WidgetTask] {
|
private func toWidgetTasks(_ tasks: [TaskResponse], overdueIds: Set<Int32>, dueWithin7DaysIds: Set<Int32>, due8To30DaysIds: Set<Int32>) -> [WidgetDataManager.WidgetTask] {
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
|
||||||
return tasks.map { task in
|
return tasks.map { task in
|
||||||
WidgetDataManager.WidgetTask(
|
// Calculate actual days until due based on effectiveDueDate
|
||||||
|
var isDueWithin7Days = false
|
||||||
|
var isDue8To30Days = false
|
||||||
|
|
||||||
|
if let dueDate = DateUtils.parseDate(task.effectiveDueDate) {
|
||||||
|
let dueDay = Calendar.current.startOfDay(for: dueDate)
|
||||||
|
let daysUntilDue = Calendar.current.dateComponents([.day], from: today, to: dueDay).day ?? 0
|
||||||
|
|
||||||
|
// Only count future tasks (not overdue)
|
||||||
|
if daysUntilDue >= 0 && daysUntilDue <= 7 {
|
||||||
|
isDueWithin7Days = true
|
||||||
|
} else if daysUntilDue > 7 && daysUntilDue <= 30 {
|
||||||
|
isDue8To30Days = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return WidgetDataManager.WidgetTask(
|
||||||
id: Int(task.id),
|
id: Int(task.id),
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description_,
|
description: task.description_,
|
||||||
@@ -525,8 +545,8 @@ class DataManagerObservable: ObservableObject {
|
|||||||
category: task.categoryName,
|
category: task.categoryName,
|
||||||
residenceName: nil,
|
residenceName: nil,
|
||||||
isOverdue: overdueIds.contains(task.id),
|
isOverdue: overdueIds.contains(task.id),
|
||||||
isDueWithin7Days: dueWithin7DaysIds.contains(task.id),
|
isDueWithin7Days: isDueWithin7Days,
|
||||||
isDue8To30Days: due8To30DaysIds.contains(task.id)
|
isDue8To30Days: isDue8To30Days
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,6 +280,21 @@ final class WidgetDataManager {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse a date string (handles both "yyyy-MM-dd" and ISO datetime formats)
|
||||||
|
/// Extracts date part if it includes time (e.g., "2025-12-26T00:00:00Z" -> "2025-12-26")
|
||||||
|
private static let dateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
static func parseDate(_ dateString: String?) -> Date? {
|
||||||
|
guard let dateString = dateString, !dateString.isEmpty else { return nil }
|
||||||
|
// Extract date part if it includes time
|
||||||
|
let datePart = dateString.components(separatedBy: "T").first ?? dateString
|
||||||
|
return dateFormatter.date(from: datePart)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the shared App Group container URL
|
/// Get the shared App Group container URL
|
||||||
private var sharedContainerURL: URL? {
|
private var sharedContainerURL: URL? {
|
||||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier)
|
||||||
@@ -300,6 +315,9 @@ final class WidgetDataManager {
|
|||||||
// Columns to exclude from widget (these are "done" states)
|
// Columns to exclude from widget (these are "done" states)
|
||||||
let excludedColumns = [Self.completedColumn, Self.cancelledColumn]
|
let excludedColumns = [Self.completedColumn, Self.cancelledColumn]
|
||||||
|
|
||||||
|
// Date calculation setup
|
||||||
|
let today = Calendar.current.startOfDay(for: Date())
|
||||||
|
|
||||||
// Extract tasks from active columns only and convert to WidgetTask
|
// Extract tasks from active columns only and convert to WidgetTask
|
||||||
var allTasks: [WidgetTask] = []
|
var allTasks: [WidgetTask] = []
|
||||||
|
|
||||||
@@ -309,12 +327,26 @@ final class WidgetDataManager {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine flags based on column name (using shared constants)
|
// isOverdue is based on column (API correctly calculates this)
|
||||||
let isOverdue = column.name == Self.overdueColumn
|
let isOverdue = column.name == Self.overdueColumn
|
||||||
let isDueWithin7Days = column.name == Self.dueWithin7DaysColumn
|
|
||||||
let isDue8To30Days = column.name == Self.due8To30DaysColumn
|
|
||||||
|
|
||||||
for task in column.tasks {
|
for task in column.tasks {
|
||||||
|
// Calculate isDueWithin7Days and isDue8To30Days from actual due date
|
||||||
|
var isDueWithin7Days = false
|
||||||
|
var isDue8To30Days = false
|
||||||
|
|
||||||
|
if let dueDate = Self.parseDate(task.effectiveDueDate) {
|
||||||
|
let dueDay = Calendar.current.startOfDay(for: dueDate)
|
||||||
|
let daysUntilDue = Calendar.current.dateComponents([.day], from: today, to: dueDay).day ?? 0
|
||||||
|
|
||||||
|
// Only count future tasks (not overdue)
|
||||||
|
if daysUntilDue >= 0 && daysUntilDue <= 7 {
|
||||||
|
isDueWithin7Days = true
|
||||||
|
} else if daysUntilDue > 7 && daysUntilDue <= 30 {
|
||||||
|
isDue8To30Days = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let widgetTask = WidgetTask(
|
let widgetTask = WidgetTask(
|
||||||
id: Int(task.id),
|
id: Int(task.id),
|
||||||
title: task.title,
|
title: task.title,
|
||||||
|
|||||||
@@ -171,6 +171,14 @@
|
|||||||
"comment" : "A link that directs users to log in if they already have an account.",
|
"comment" : "A link that directs users to log in if they already have an account.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Animation Testing" : {
|
||||||
|
"comment" : "The title of a view that tests different animations.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Animation Type" : {
|
||||||
|
"comment" : "A label above the picker for selecting an animation type.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"app_name" : {
|
"app_name" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -17440,6 +17448,10 @@
|
|||||||
},
|
},
|
||||||
"No shared users" : {
|
"No shared users" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"No Tasks" : {
|
||||||
|
"comment" : "A description displayed when there are no tasks to display in a column view.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"No tasks yet" : {
|
"No tasks yet" : {
|
||||||
"comment" : "A description displayed when a user has no tasks.",
|
"comment" : "A description displayed when a user has no tasks.",
|
||||||
@@ -21558,6 +21570,10 @@
|
|||||||
"comment" : "A button that replays the current animation.",
|
"comment" : "A button that replays the current animation.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
|
"Reset All Tasks" : {
|
||||||
|
"comment" : "A button label that resets all tasks.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Reset Password" : {
|
"Reset Password" : {
|
||||||
"comment" : "The title of the screen where users can reset their passwords.",
|
"comment" : "The title of the screen where users can reset their passwords.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnimationTestingCard: View {
|
||||||
|
let task: TestTask
|
||||||
|
let onComplete: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||||
|
// Header with title and priority
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
Text(task.title)
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
priorityBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Description
|
||||||
|
if let description = task.description {
|
||||||
|
Text(description)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata pills
|
||||||
|
HStack(spacing: AppSpacing.sm) {
|
||||||
|
metadataPill(icon: "repeat", text: task.frequency)
|
||||||
|
Spacer()
|
||||||
|
metadataPill(icon: "calendar", text: task.dueDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete button
|
||||||
|
Button(action: onComplete) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
Text("Complete")
|
||||||
|
.font(.system(size: 14, weight: .semibold, design: .rounded))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, AppSpacing.sm)
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.background(Color.appPrimary)
|
||||||
|
.cornerRadius(AppRadius.md)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(AppSpacing.md)
|
||||||
|
.background(Color.appBackgroundSecondary)
|
||||||
|
.cornerRadius(AppRadius.lg)
|
||||||
|
.shadow(color: Color.black.opacity(0.1), radius: 5, x: 0, y: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var priorityBadge: some View {
|
||||||
|
Text(task.priority.rawValue)
|
||||||
|
.font(.system(size: 10, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(task.priority.color)
|
||||||
|
.cornerRadius(AppRadius.sm)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func metadataPill(icon: String, text: String) -> some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 10))
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
}
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.appBackgroundPrimary.opacity(0.6))
|
||||||
|
.cornerRadius(AppRadius.sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color.appBackgroundPrimary.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
AnimationTestingCard(task: TestTask.samples[0]) {
|
||||||
|
print("Complete tapped")
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimationTestingCard(task: TestTask.samples[1]) {
|
||||||
|
print("Complete tapped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AnimationTestingView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
// Animation selection
|
||||||
|
@State private var selectedAnimation: TaskAnimationType = .slide
|
||||||
|
|
||||||
|
// Fake task data
|
||||||
|
@State private var columns: [TestColumn] = TestColumn.defaultColumns
|
||||||
|
|
||||||
|
// Track animating task
|
||||||
|
@State private var animatingTaskId: String? = nil
|
||||||
|
@State private var animationPhase: AnimationPhase = .idle
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ZStack {
|
||||||
|
WarmGradientBackground()
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Animation picker at top
|
||||||
|
animationPicker
|
||||||
|
|
||||||
|
// Kanban columns
|
||||||
|
kanbanBoard
|
||||||
|
|
||||||
|
// Reset button at bottom
|
||||||
|
resetButton
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Animation Testing")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation Picker
|
||||||
|
|
||||||
|
private var animationPicker: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppSpacing.xs) {
|
||||||
|
HStack {
|
||||||
|
Text("Animation Type")
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(selectedAnimation.description)
|
||||||
|
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: AppSpacing.xs) {
|
||||||
|
ForEach(TaskAnimationType.allCases) { animation in
|
||||||
|
AnimationChip(
|
||||||
|
animation: animation,
|
||||||
|
isSelected: selectedAnimation == animation,
|
||||||
|
onSelect: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
selectedAnimation = animation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppSpacing.md)
|
||||||
|
.padding(.vertical, AppSpacing.sm)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.95))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Kanban Board
|
||||||
|
|
||||||
|
private var kanbanBoard: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(alignment: .top, spacing: AppSpacing.md) {
|
||||||
|
ForEach(columns) { column in
|
||||||
|
TestColumnView(
|
||||||
|
column: column,
|
||||||
|
columnWidth: min(geometry.size.width * 0.75, 300),
|
||||||
|
selectedAnimation: selectedAnimation,
|
||||||
|
animatingTaskId: animatingTaskId,
|
||||||
|
animationPhase: animationPhase,
|
||||||
|
onComplete: { task in
|
||||||
|
completeTask(task, fromColumn: column)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppSpacing.md)
|
||||||
|
.padding(.vertical, AppSpacing.sm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reset Button
|
||||||
|
|
||||||
|
private var resetButton: some View {
|
||||||
|
Button(action: resetTasks) {
|
||||||
|
HStack(spacing: AppSpacing.sm) {
|
||||||
|
Image(systemName: "arrow.counterclockwise")
|
||||||
|
Text("Reset All Tasks")
|
||||||
|
}
|
||||||
|
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appPrimary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, AppSpacing.md)
|
||||||
|
.background(Color.appPrimary.opacity(0.1))
|
||||||
|
.cornerRadius(AppRadius.md)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppSpacing.md)
|
||||||
|
.padding(.bottom, AppSpacing.md)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Actions
|
||||||
|
|
||||||
|
private func completeTask(_ task: TestTask, fromColumn: TestColumn) {
|
||||||
|
guard animatingTaskId == nil else { return } // Prevent concurrent animations
|
||||||
|
|
||||||
|
// Find the next column
|
||||||
|
guard let currentIndex = columns.firstIndex(where: { $0.id == fromColumn.id }),
|
||||||
|
currentIndex < columns.count - 1 else {
|
||||||
|
return // Already in last column
|
||||||
|
}
|
||||||
|
|
||||||
|
animatingTaskId = task.id
|
||||||
|
|
||||||
|
// Extended timing animations: shrink card, show checkmark, THEN move task
|
||||||
|
if selectedAnimation.needsExtendedTiming {
|
||||||
|
// Phase 1: Start shrinking
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .exiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Fully shrunk, show checkmark
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .complete // This triggers the checkmark overlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: After checkmark animation, move the task
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.2) {
|
||||||
|
// Remove from current column
|
||||||
|
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
|
||||||
|
columns[currentIndex].tasks.remove(at: taskIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to next column
|
||||||
|
columns[currentIndex + 1].tasks.insert(task, at: 0)
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard animations: move task during animation
|
||||||
|
// Phase 1: Exiting
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .exiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Moving (after short delay)
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .moving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Actually move the task and enter
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
|
||||||
|
// Remove from current column
|
||||||
|
if let taskIndex = columns[currentIndex].tasks.firstIndex(where: { $0.id == task.id }) {
|
||||||
|
columns[currentIndex].tasks.remove(at: taskIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to next column
|
||||||
|
columns[currentIndex + 1].tasks.insert(task, at: 0)
|
||||||
|
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .entering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 4: Complete
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.55) {
|
||||||
|
withAnimation {
|
||||||
|
animationPhase = .complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset animation state
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetTasks() {
|
||||||
|
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
|
||||||
|
columns = TestColumn.defaultColumns
|
||||||
|
animatingTaskId = nil
|
||||||
|
animationPhase = .idle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Test Column View
|
||||||
|
|
||||||
|
struct TestColumnView: View {
|
||||||
|
let column: TestColumn
|
||||||
|
let columnWidth: CGFloat
|
||||||
|
let selectedAnimation: TaskAnimationType
|
||||||
|
let animatingTaskId: String?
|
||||||
|
let animationPhase: AnimationPhase
|
||||||
|
let onComplete: (TestTask) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppSpacing.sm) {
|
||||||
|
// Column header
|
||||||
|
HStack(spacing: AppSpacing.xs) {
|
||||||
|
Circle()
|
||||||
|
.fill(column.color)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
|
||||||
|
Text(column.displayName)
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("\(column.tasks.count)")
|
||||||
|
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextOnPrimary)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(column.color)
|
||||||
|
.cornerRadius(AppRadius.sm)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, AppSpacing.sm)
|
||||||
|
.padding(.vertical, AppSpacing.xs)
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
if column.tasks.isEmpty {
|
||||||
|
emptyState
|
||||||
|
} else {
|
||||||
|
ScrollView(.vertical, showsIndicators: false) {
|
||||||
|
VStack(spacing: AppSpacing.sm) {
|
||||||
|
ForEach(column.tasks) { task in
|
||||||
|
AnimationTestingCard(task: task) {
|
||||||
|
onComplete(task)
|
||||||
|
}
|
||||||
|
.taskAnimation(
|
||||||
|
type: selectedAnimation,
|
||||||
|
phase: animatingTaskId == task.id ? animationPhase : .idle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(width: columnWidth)
|
||||||
|
.padding(AppSpacing.sm)
|
||||||
|
.background(Color.appBackgroundSecondary.opacity(0.5))
|
||||||
|
.cornerRadius(AppRadius.lg)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var emptyState: some View {
|
||||||
|
VStack(spacing: AppSpacing.sm) {
|
||||||
|
Image(systemName: "tray")
|
||||||
|
.font(.system(size: 32))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.4))
|
||||||
|
|
||||||
|
Text("No Tasks")
|
||||||
|
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||||
|
.foregroundColor(Color.appTextSecondary.opacity(0.6))
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, AppSpacing.xl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
AnimationTestingView()
|
||||||
|
}
|
||||||
1055
iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift
Normal file
1055
iosApp/iosApp/Profile/AnimationTesting/TaskAnimations.swift
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ struct ProfileTabView: View {
|
|||||||
@State private var showUpgradePrompt = false
|
@State private var showUpgradePrompt = false
|
||||||
@State private var showRestoreSuccess = false
|
@State private var showRestoreSuccess = false
|
||||||
@State private var showingNotificationPreferences = false
|
@State private var showingNotificationPreferences = false
|
||||||
|
@State private var showingAnimationTesting = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
List {
|
||||||
@@ -153,6 +154,21 @@ struct ProfileTabView: View {
|
|||||||
.foregroundColor(Color.appTextSecondary)
|
.foregroundColor(Color.appTextSecondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
showingAnimationTesting = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Label("Animation Testing", systemImage: "sparkles.rectangle.stack")
|
||||||
|
.foregroundColor(Color.appTextPrimary)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(Color.appTextSecondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.sectionBackground()
|
.sectionBackground()
|
||||||
|
|
||||||
@@ -209,6 +225,9 @@ struct ProfileTabView: View {
|
|||||||
.sheet(isPresented: $showingNotificationPreferences) {
|
.sheet(isPresented: $showingNotificationPreferences) {
|
||||||
NotificationPreferencesView()
|
NotificationPreferencesView()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showingAnimationTesting) {
|
||||||
|
AnimationTestingView()
|
||||||
|
}
|
||||||
.alert(L10n.Profile.logout, isPresented: $showingLogoutAlert) {
|
.alert(L10n.Profile.logout, isPresented: $showingLogoutAlert) {
|
||||||
Button(L10n.Common.cancel, role: .cancel) { }
|
Button(L10n.Common.cancel, role: .cancel) { }
|
||||||
Button(L10n.Profile.logout, role: .destructive) {
|
Button(L10n.Profile.logout, role: .destructive) {
|
||||||
|
|||||||
Reference in New Issue
Block a user