feat: My Tasks widget, event countdown redesign, completed section

- My Tasks widget: today's tasks with interactive check-off (ToggleTaskIntent)
  and a "+" that opens the app's add-task sheet; writes preserve all task
  fields and sync via the App Group.
- Event Countdown: AZURE/AWS-style header (accent name + countdown label +
  percent); tap the label to cycle days → weeks → time remaining.
- Today: collapsible "Completed" archive section below Upcoming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-03 13:31:04 +03:00
parent 8b094691f5
commit 646cdf6a9b
10 changed files with 386 additions and 44 deletions

View File

@@ -16,6 +16,7 @@
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; }; 3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */; };
3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; }; 3B57EA3600AFE975850DF39A /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89550F2CD19B950CCC6AD37F /* AuthManager.swift */; };
3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; }; 3C793FD5DA00D3E9C0D51FEC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 72FDF9C8DD37134576356B89 /* Assets.xcassets */; };
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */; };
497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; }; 497732557745AE9BDA44FB2F /* ShortcutHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */; };
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; }; 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */; };
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; }; 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 429806CE1021C8DE2EB770CE /* WidgetViews.swift */; };
@@ -70,9 +71,10 @@
001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; }; 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalApp.swift; sourceTree = "<group>"; };
01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; }; 01A27D42E141DC056D32C1A3 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; }; 0C96EC00F6B021DBA3CC1A79 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = KisaniCalWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; };
106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; }; 106EEF572C6F8990408329F0 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; }; 1AA7498EFED7692022F3E7E1 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; }; 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutView.swift; sourceTree = "<group>"; };
23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; }; 23A4491BFA50721082024756 /* SharedComponents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedComponents.swift; sourceTree = "<group>"; };
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; }; 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutHandler.swift; sourceTree = "<group>"; };
@@ -91,7 +93,7 @@
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; }; 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCalWidgets.entitlements; sourceTree = "<group>"; };
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; };
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; }; AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -183,6 +185,7 @@
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */, 8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */,
8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */, 8E7FC6446D5C5B2A2D9387DA /* KisaniCalWidgets.entitlements */,
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */, 42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */,
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */,
61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */, 61BFF3BA28331A16D0ADE9D0 /* WidgetData.swift */,
429806CE1021C8DE2EB770CE /* WidgetViews.swift */, 429806CE1021C8DE2EB770CE /* WidgetViews.swift */,
); );
@@ -267,7 +270,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2620; LastUpgradeCheck = 1500;
TargetAttributes = { TargetAttributes = {
28B43516AD88946E21D9BFE0 = { 28B43516AD88946E21D9BFE0 = {
DevelopmentTeam = K8BLMMR883; DevelopmentTeam = K8BLMMR883;
@@ -351,6 +354,7 @@
12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */, 12E42CE8B8E535FAE6268A0C /* AppGroup.swift in Sources */,
552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */, 552E2F85B01C9CC437D383B5 /* EventCountdownWidget.swift in Sources */,
C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */, C7767143D9617ECA04ED1935 /* KisaniCalWidgets.swift in Sources */,
3E9DE1CF20BAC479805DF940 /* MyTasksWidget.swift in Sources */,
AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */, AC0D814DA54D5EF5E25CEB99 /* WidgetData.swift in Sources */,
55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */, 55F00C433F853F7B54F136B3 /* WidgetViews.swift in Sources */,
); );
@@ -438,7 +442,6 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_NS_ASSERTIONS = NO; ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
@@ -453,7 +456,6 @@
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -469,11 +471,7 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;
@@ -520,7 +518,6 @@
DEVELOPMENT_TEAM = K8BLMMR883; DEVELOPMENT_TEAM = K8BLMMR883;
ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11; GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO; GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES; GCC_NO_COMMON_BLOCKS = YES;
@@ -542,7 +539,6 @@
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -558,11 +554,7 @@
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = KisaniCalWidgets/Info.plist; INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 18.0; IPHONEOS_DEPLOYMENT_TARGET = 18.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
SDKROOT = iphoneos; SDKROOT = iphoneos;
SWIFT_VERSION = 5.9; SWIFT_VERSION = 5.9;

View File

@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "2620" LastUpgradeVersion = "1500"
version = "1.3"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries> <BuildActionEntries>
<BuildActionEntry <BuildActionEntry
buildForTesting = "YES" buildForTesting = "YES"
@@ -26,7 +27,8 @@
buildConfiguration = "Debug" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"> shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion> <MacroExpansion>
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
@@ -38,6 +40,8 @@
</MacroExpansion> </MacroExpansion>
<Testables> <Testables>
</Testables> </Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Debug" buildConfiguration = "Debug"
@@ -59,6 +63,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"
@@ -76,6 +82,8 @@
ReferencedContainer = "container:KisaniCal.xcodeproj"> ReferencedContainer = "container:KisaniCal.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction> </ProfileAction>
<AnalyzeAction <AnalyzeAction
buildConfiguration = "Debug"> buildConfiguration = "Debug">

View File

@@ -68,6 +68,14 @@ struct ContentView: View {
private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad } private var isIPad: Bool { UIDevice.current.userInterfaceIdiom == .pad }
/// Picks up the "+" tapped on the My Tasks widget and opens the add-task sheet.
private func consumeWidgetAddTask() {
guard UserDefaults.kisani.bool(forKey: "kisani.widget.openAddTask") else { return }
UserDefaults.kisani.removeObject(forKey: "kisani.widget.openAddTask")
selectedTab = 0
showQuickAddTask = true
}
var body: some View { var body: some View {
Group { Group {
if isIPad { if isIPad {
@@ -89,6 +97,7 @@ struct ContentView: View {
CloudSyncManager.shared.backupSettings() CloudSyncManager.shared.backupSettings()
HealthKitManager.shared.bootstrap() HealthKitManager.shared.bootstrap()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
consumeWidgetAddTask()
// Pull workouts from Health, then reschedule so a detected workout updates reminders. // Pull workouts from Health, then reschedule so a detected workout updates reminders.
Task { Task {
await workoutVM.syncFromHealthKit() await workoutVM.syncFromHealthKit()
@@ -98,6 +107,7 @@ struct ContentView: View {
.onChange(of: scenePhase) { phase in .onChange(of: scenePhase) { phase in
if phase == .active { if phase == .active {
taskVM.reload() taskVM.reload()
consumeWidgetAddTask()
workoutVM.checkPendingHealthConfirm() workoutVM.checkPendingHealthConfirm()
CloudSyncManager.shared.backupSettings() CloudSyncManager.shared.backupSettings()
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)

View File

@@ -200,6 +200,12 @@ class TaskViewModel: ObservableObject {
.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay } .filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) } .sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
} }
/// All completed tasks, most-recent first (for the Completed archive section).
var completedTasks: [TaskItem] {
tasks
.filter { $0.isComplete }
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
}
func toggle(_ task: TaskItem) { func toggle(_ task: TaskItem) {
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }

View File

@@ -123,6 +123,16 @@ struct TodayView: View {
) )
} }
// Completed (all, collapsible)
if !taskVM.completedTasks.isEmpty {
CompletedSection(
tasks: taskVM.completedTasks,
onUndo: { taskVM.toggle($0) }
)
.padding(.horizontal, 18)
.padding(.top, 10)
}
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|| !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty || !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|| !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty || !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty
@@ -1009,6 +1019,74 @@ struct CompletedTodaySection: View {
} }
} }
// MARK: - Completed Section (all completed, collapsible archive)
struct CompletedSection: View {
@Environment(\.colorScheme) private var cs
let tasks: [TaskItem]
let onUndo: (TaskItem) -> Void
@State private var expanded = false
private let dateFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
}()
var body: some View {
VStack(spacing: 0) {
// Header
HStack {
Text("Completed")
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(AppColors.text2(cs))
Spacer()
Text("\(tasks.count)")
.font(AppFonts.mono(11))
.foregroundColor(AppColors.text3(cs))
Image(systemName: "chevron.down")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(AppColors.text3(cs))
.rotationEffect(.degrees(expanded ? 0 : -90))
.animation(KisaniSpring.micro, value: expanded)
}
.padding(.horizontal, 14).padding(.vertical, 12)
.contentShape(Rectangle())
.onTapGesture { withAnimation(KisaniSpring.snappy) { expanded.toggle() } }
if expanded {
ForEach(tasks) { task in
HStack(spacing: 12) {
Button {
withAnimation(KisaniSpring.snappy) { onUndo(task) }
} label: {
Image(systemName: "checkmark.square.fill")
.font(.system(size: 22))
.foregroundColor(AppColors.text3(cs).opacity(0.6))
}
.buttonStyle(.plain)
Text(task.title)
.font(AppFonts.sans(14))
.foregroundColor(AppColors.text3(cs))
.strikethrough(true, color: AppColors.text3(cs).opacity(0.7))
.lineLimit(1)
Spacer(minLength: 8)
if let d = task.completedAt {
Text(dateFmt.string(from: d))
.font(AppFonts.sans(13))
.foregroundColor(AppColors.text3(cs))
}
}
.padding(.horizontal, 14).padding(.vertical, 10)
}
.padding(.bottom, 4)
}
}
.background(AppColors.surface(cs))
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
}
}
// MARK: - Task Row // MARK: - Task Row
struct TaskRowView: View { struct TaskRowView: View {
@Environment(\.colorScheme) private var cs @Environment(\.colorScheme) private var cs

View File

@@ -67,11 +67,25 @@ struct EventQuery: EntityQuery {
// MARK: - Entry // MARK: - Entry
// Tapping the countdown label cycles this unit (shared by all event widgets).
let countdownUnitKey = "kisani.widget.countdownUnit" // 0 = days, 1 = weeks, 2 = time
struct CycleCountdownUnitIntent: AppIntent {
static var title: LocalizedStringResource = "Change Countdown Unit"
func perform() async throws -> some IntentResult {
let cur = UserDefaults.kisani.integer(forKey: countdownUnitKey)
UserDefaults.kisani.set((cur + 1) % 3, forKey: countdownUnitKey)
return .result()
}
}
struct EventEntry: TimelineEntry { struct EventEntry: TimelineEntry {
let date: Date let date: Date
let eventName: String let eventName: String
let start: Date let start: Date
let target: Date let target: Date
var unitMode: Int = 0 // 0 days, 1 weeks, 2 time
/// Fraction of the countdown window that has elapsed (01). /// Fraction of the countdown window that has elapsed (01).
var progress: Double { var progress: Double {
@@ -97,6 +111,30 @@ struct EventEntry: TimelineEntry {
to: cal.startOfDay(for: target)).day ?? 0 to: cal.startOfDay(for: target)).day ?? 0
return max(0, d) return max(0, d)
} }
/// Countdown from the current date. Tapping the label cycles the unit:
/// days weeks time remaining.
var timeLeftLabel: String {
if target <= date { return "Today" }
let cal = Calendar.current
switch unitMode {
case 1: // weeks
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
if days < 7 { return "< 1 week left" }
let weeks = days / 7
return "\(weeks) week\(weeks == 1 ? "" : "s") left"
case 2: // time remaining (detailed)
let c = cal.dateComponents([.day, .hour], from: date, to: target)
let days = c.day ?? 0, hours = c.hour ?? 0
if days == 0 { return hours <= 0 ? "Due now" : "\(hours) hr left" }
return hours > 0 ? "\(days)d, \(hours) hr left" : "\(days) day\(days == 1 ? "" : "s") left"
default: // days
let days = cal.dateComponents([.day], from: cal.startOfDay(for: date),
to: cal.startOfDay(for: target)).day ?? 0
return "\(days) day\(days == 1 ? "" : "s") left"
}
}
} }
// MARK: - Provider // MARK: - Provider
@@ -133,7 +171,8 @@ struct EventProvider: AppIntentTimelineProvider {
} }
// Never let the start sit after the target. // Never let the start sit after the target.
let start = min(config.startDate, target) let start = min(config.startDate, target)
return EventEntry(date: .now, eventName: name, start: start, target: target) let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
} }
} }

View File

@@ -106,6 +106,7 @@ struct LockScreenCircularWidget: Widget {
struct KisaniWidgetBundle: WidgetBundle { struct KisaniWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
EventCountdownWidget() EventCountdownWidget()
MyTasksWidget()
DayProgressWidget() DayProgressWidget()
TasksCompleteWidget() TasksCompleteWidget()
LockScreenWidget() LockScreenWidget()

View File

@@ -0,0 +1,148 @@
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Interactive intents
/// Tapping a checkbox toggles the task's completion directly in the App Group.
struct ToggleTaskIntent: AppIntent {
static var title: LocalizedStringResource = "Toggle Task Complete"
@Parameter(title: "Task ID")
var taskId: String
init() {}
init(taskId: String) { self.taskId = taskId }
func perform() async throws -> some IntentResult {
toggleWidgetTaskComplete(id: taskId)
return .result()
}
}
/// The "+" button opens the app and asks it to present the add-task sheet.
struct AddTaskFromWidgetIntent: AppIntent {
static var title: LocalizedStringResource = "Add Task"
static var openAppWhenRun = true
func perform() async throws -> some IntentResult {
UserDefaults.kisani.set(true, forKey: "kisani.widget.openAddTask")
return .result()
}
}
// MARK: - Entry + Provider
struct TasksEntry: TimelineEntry {
let date: Date
let tasks: [WidgetTask]
let openCount: Int
}
struct TasksProvider: TimelineProvider {
func placeholder(in context: Context) -> TasksEntry {
TasksEntry(date: .now, tasks: [
WidgetTask(id: UUID(), title: "Pray for Uganda", dueDate: .now, isComplete: false,
quadrant: "Urgent + Important", category: "reminder"),
WidgetTask(id: UUID(), title: "Masters of the Universe", dueDate: .now, isComplete: false,
quadrant: "Schedule It", category: "reminder")
], openCount: 2)
}
func getSnapshot(in context: Context, completion: @escaping (TasksEntry) -> Void) {
completion(loadEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<TasksEntry>) -> Void) {
let next = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
completion(Timeline(entries: [loadEntry()], policy: .after(next)))
}
private func loadEntry() -> TasksEntry {
let tasks = todayWidgetTasks()
return TasksEntry(date: .now, tasks: tasks, openCount: tasks.filter { !$0.isComplete }.count)
}
}
// MARK: - Widget
struct MyTasksWidget: Widget {
let kind = "KisaniMyTasks"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: TasksProvider()) { entry in
MyTasksView(entry: entry)
}
.configurationDisplayName("My Tasks")
.description("Today's tasks — check them off right from the widget.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct MyTasksView: View {
let entry: TasksEntry
@Environment(\.widgetFamily) var family
private var maxRows: Int {
switch family {
case .systemLarge: return 9
case .systemMedium: return 4
default: return 3
}
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Text("Today")
.font(.system(size: 18, weight: .bold))
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
Text("\(entry.openCount)")
.font(.system(size: 15, weight: .semibold))
.foregroundColor(Color(white: 0.5))
Spacer()
Button(intent: AddTaskFromWidgetIntent()) {
Image(systemName: "plus")
.font(.system(size: 16, weight: .semibold))
.foregroundColor(Color(red: 0.42, green: 0.5, blue: 0.96))
}
.buttonStyle(.plain)
}
if entry.tasks.isEmpty {
Spacer()
Text("All clear for today 🎉")
.font(.system(size: 13))
.foregroundColor(Color(white: 0.5))
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
} else {
ForEach(entry.tasks.prefix(maxRows)) { task in
HStack(spacing: 10) {
Button(intent: ToggleTaskIntent(taskId: task.id.uuidString)) {
Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle")
.font(.system(size: 18))
.foregroundColor(task.isComplete ? task.accentColor : Color(white: 0.45))
}
.buttonStyle(.plain)
Text(task.title)
.font(.system(size: 14))
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
.strikethrough(task.isComplete, color: Color(white: 0.45))
.lineLimit(1)
Spacer(minLength: 0)
}
}
if entry.tasks.count > maxRows {
Text("+\(entry.tasks.count - maxRows) more")
.font(.system(size: 11, weight: .medium))
.foregroundColor(Color(white: 0.4))
.padding(.leading, 28)
}
Spacer(minLength: 0)
}
}
.containerBackground(.black, for: .widget)
}
}

View File

@@ -1,5 +1,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import WidgetKit
// Minimal task model shared between widget and main app via App Group // Minimal task model shared between widget and main app via App Group
struct WidgetTask: Codable, Identifiable { struct WidgetTask: Codable, Identifiable {
@@ -84,6 +85,59 @@ func nextDueTask(from tasks: [WidgetTask]) -> WidgetTask? {
.first .first
} }
private func activeTasksKey() -> String {
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
return "kisani.tasks.v2.\(uid)"
}
// Tasks due today (incomplete first, then by due time) for the interactive list widget.
func todayWidgetTasks() -> [WidgetTask] {
let cal = Calendar.current
let start = cal.startOfDay(for: Date())
let end = cal.date(byAdding: .day, value: 1, to: start)!
return loadWidgetTasks()
.filter { t in
guard let due = t.dueDate else { return false }
return due >= start && due < end
}
.sorted {
if $0.isComplete != $1.isComplete { return !$0.isComplete }
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
}
}
// Toggle a task's completion from the widget. Uses JSONSerialization so ALL task
// fields are preserved on write (the widget only models a subset of TaskItem).
func toggleWidgetTaskComplete(id: String) {
let key = activeTasksKey()
guard let data = UserDefaults.kisani.data(forKey: key),
var arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]]
else { return }
for i in arr.indices where (arr[i]["id"] as? String) == id {
let done = (arr[i]["isComplete"] as? Bool) ?? false
arr[i]["isComplete"] = !done
if !done {
// TaskItem uses the default (deferredToDate) Date strategy seconds since reference date.
arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate
} else {
arr[i].removeValue(forKey: "completedAt")
}
}
if let out = try? JSONSerialization.data(withJSONObject: arr) {
UserDefaults.kisani.set(out, forKey: key)
CloudSyncManager_widgetPush(out, key)
}
WidgetCenter.shared.reloadAllTimelines()
}
// The widget extension can't see CloudSyncManager; mirror to iCloud KV store directly.
private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
guard data.count < 900_000 else { return }
let kv = NSUbiquitousKeyValueStore.default
kv.set(data, forKey: key)
kv.synchronize()
}
// Today's task completion: (done, total) for tasks due today. // Today's task completion: (done, total) for tasks due today.
func todayTaskCompletion() -> (done: Int, total: Int) { func todayTaskCompletion() -> (done: Int, total: Int) {
let cal = Calendar.current let cal = Calendar.current

View File

@@ -191,22 +191,31 @@ struct EventCountdownView: View {
.containerBackground(.black, for: .widget) .containerBackground(.black, for: .widget)
} }
// Wide layout that mirrors the reference: title + percent, then a dot grid. private var filled: Int { Int(entry.progress * Double(entry.totalDays)) }
// Wide layout: event name + days-left countdown, then the dot-grid progress.
private var medium: some View { private var medium: some View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(LocalizedStringKey(entry.eventName)) Text(LocalizedStringKey(entry.eventName))
.font(.system(size: 22, weight: .bold)) .font(.system(size: 20, weight: .bold))
.foregroundColor(.white) .foregroundColor(teal)
.lineLimit(1) .lineLimit(1)
Spacer() Spacer(minLength: 4)
Text("\(percent)%") VStack(alignment: .trailing, spacing: 1) {
.font(.system(size: 22, weight: .bold)) Button(intent: CycleCountdownUnitIntent()) {
.foregroundColor(Color(white: 0.5)) Text(entry.timeLeftLabel)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color(white: 0.62))
.lineLimit(1)
}
.buttonStyle(.plain)
Text("\(percent)%")
.font(.system(size: 11, weight: .bold))
.foregroundColor(Color(white: 0.4))
}
} }
DotGrid(total: entry.totalDays, DotGrid(total: entry.totalDays, filled: filled, color: teal)
filled: Int(entry.progress * Double(entry.totalDays)),
color: teal)
} }
.padding(.horizontal, 4) .padding(.horizontal, 4)
} }
@@ -215,20 +224,17 @@ struct EventCountdownView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(LocalizedStringKey(entry.eventName)) Text(LocalizedStringKey(entry.eventName))
.font(.system(size: 14, weight: .bold)) .font(.system(size: 14, weight: .bold))
.foregroundColor(.white) .foregroundColor(teal)
.lineLimit(2) .lineLimit(2)
HStack(alignment: .firstTextBaseline, spacing: 4) { Button(intent: CycleCountdownUnitIntent()) {
Text("\(entry.daysLeft)") Text(entry.timeLeftLabel)
.font(.system(size: 28, weight: .bold, design: .rounded)) .font(.system(size: 12, weight: .semibold))
.foregroundColor(teal) .foregroundColor(Color(white: 0.62))
Text(entry.daysLeft == 1 ? "day" : "days") .lineLimit(1)
.font(.system(size: 12))
.foregroundColor(Color(white: 0.5))
} }
.buttonStyle(.plain)
Spacer(minLength: 2) Spacer(minLength: 2)
DotGrid(total: entry.totalDays, DotGrid(total: entry.totalDays, filled: filled, color: teal)
filled: Int(entry.progress * Double(entry.totalDays)),
color: teal)
} }
} }
} }