Today menu features, Matrix Move trim, widget auto-advance, Wenza logo (KC-23–26)
- Today ⋯ menu: implemented the placeholder actions.
- Background: 5 selectable themes (Default/Warm/Cool/Mono/Paper), persisted.
- Group & Sort: group by Date (default)/Priority/Category/Quadrant and sort by
Smart/Due/Priority/Title; non-date groupings render via UpcomingSection.
- Select: multi-select sheet with Select All/Deselect All and bulk
Complete/Priority/Move/Delete.
New isolated file TodayMenuFeatures.swift; added TaskViewModel.allActiveRows.
- Matrix "Move" submenu now lists only the other three quadrants (excludes the
current one) using clear matrix labels.
- Event Countdown (Bars) widget auto-advances: each timeline entry re-resolves
"nearest to finish" as of its own time and refreshes at the moment the current
event expires, so it rolls to the next event on expiry/completion.
- Splash wordmark rebranded to "wenza." (regular-weight typewriter + orange dot).
- ISSUES.md: logged KC-23 (Pick Date + time-based reminders), KC-24 (Wenza
rebrand), KC-25 (Move trim), KC-26 (widget auto-advance).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; };
|
754D3DE3CEB998E36E585A61 /* LiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0506183945D16EC443A69651 /* LiveActivityManager.swift */; };
|
||||||
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
79FC1DF6762C6F02D01AB643 /* KisaniCalWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
||||||
|
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
||||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
||||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
||||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
||||||
@@ -80,7 +81,7 @@
|
|||||||
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
0506183945D16EC443A69651 /* LiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityManager.swift; sourceTree = "<group>"; };
|
||||||
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.swift; sourceTree = "<group>"; };
|
0513EB8C7F6B6853B9E93E09 /* TaskLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskLiveActivity.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>"; };
|
208F82348DEBD9FF7B0DCF17 /* MyTasksWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyTasksWidget.swift; sourceTree = "<group>"; };
|
||||||
@@ -104,7 +105,7 @@
|
|||||||
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>"; };
|
||||||
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; sourceTree = "<group>"; };
|
92824ED40ECD41EFD4F78BEC /* ISSUES.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = ISSUES.md; 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>"; };
|
||||||
@@ -114,6 +115,7 @@
|
|||||||
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
C786EBC7DF879D64EB28165E /* TodayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayView.swift; sourceTree = "<group>"; };
|
||||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
|
D44530A77DF12A17E52AAF34 /* MatrixView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatrixView.swift; sourceTree = "<group>"; };
|
||||||
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
DCC2AFB4FA765383740767CB /* TaskItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskItem.swift; sourceTree = "<group>"; };
|
||||||
|
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TodayMenuFeatures.swift; sourceTree = "<group>"; };
|
||||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||||
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
|
FC17C7BA15BD2E84AE5F77CF /* EventCountdownWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventCountdownWidget.swift; sourceTree = "<group>"; };
|
||||||
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; };
|
FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskActivityAttributes.swift; sourceTree = "<group>"; };
|
||||||
@@ -169,6 +171,7 @@
|
|||||||
72308FEE0226F45414C04DDD /* SettingsView.swift */,
|
72308FEE0226F45414C04DDD /* SettingsView.swift */,
|
||||||
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
|
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
|
||||||
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
|
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
|
||||||
|
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
|
||||||
C786EBC7DF879D64EB28165E /* TodayView.swift */,
|
C786EBC7DF879D64EB28165E /* TodayView.swift */,
|
||||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
|
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
|
||||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
|
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
|
||||||
@@ -289,7 +292,7 @@
|
|||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 2620;
|
LastUpgradeCheck = 1500;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
28B43516AD88946E21D9BFE0 = {
|
28B43516AD88946E21D9BFE0 = {
|
||||||
DevelopmentTeam = K8BLMMR883;
|
DevelopmentTeam = K8BLMMR883;
|
||||||
@@ -364,6 +367,7 @@
|
|||||||
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
|
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
|
||||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
|
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
|
||||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */,
|
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */,
|
||||||
|
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */,
|
||||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
||||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
||||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
||||||
@@ -469,7 +473,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;
|
||||||
@@ -484,7 +487,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;
|
||||||
@@ -500,11 +502,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;
|
||||||
@@ -551,7 +549,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;
|
||||||
@@ -573,7 +570,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;
|
||||||
@@ -589,11 +585,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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -751,8 +751,126 @@ Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`,
|
|||||||
### How to test
|
### How to test
|
||||||
1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
|
1. Long-press a task in Today → Postpone → 15 Minutes; reminder/due time moves.
|
||||||
2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
|
2. Repeat from Matrix grid, Matrix detail, and Calendar day timeline.
|
||||||
3. Open Move from the same menu → all four quadrants are visible, current one checked.
|
3. Open Move from the same menu → only the other three quadrants are listed (see KC-25).
|
||||||
4. Trigger a task reminder notification on device → verify 15/30/60/120 minute
|
4. Trigger a task reminder notification on device → verify 15/30/60/120 minute
|
||||||
lock-screen actions appear.
|
lock-screen actions appear.
|
||||||
5. Tap a lock-screen snooze action → task reminder updates and notification
|
5. Tap a lock-screen snooze action → task reminder updates and notification
|
||||||
re-delivers after the selected interval.
|
re-delivers after the selected interval.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-23 — "Pick Date" everywhere + sensible time-based reminders
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Tasks / Matrix / Calendar / Reminders
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
The Date submenu offered "Pick Date" only in the Today view — the Matrix grid
|
||||||
|
cards and the Calendar reschedule/move menu were missing it. Separately, the
|
||||||
|
reminder lead-time options were day/week based ("2 days early", "1 week early"),
|
||||||
|
which is unrealistic for timed events; the user wanted the standard time-based
|
||||||
|
set.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- "Pick Date" (and Edit) now appear in the Matrix grid cards and the Calendar day
|
||||||
|
timeline menu — both open the shared `TaskEditSheet` via a new `editingTask`
|
||||||
|
sheet (`QuadrantCard` gained an `onEdit`; Calendar passes `onEdit` into the
|
||||||
|
task menu).
|
||||||
|
- Reworked the reminder picker (`TaskDatePickerSheet`, shared by every entry
|
||||||
|
point) from day-offsets to **minutes before the event time**: None, On time,
|
||||||
|
5 min, 30 min, 1 hour, 1 day, plus a Minutes/Hours/Days custom wheel. Existing
|
||||||
|
absolute `reminderDate`s migrate to the nearest minute offset on open.
|
||||||
|
|
||||||
|
Files: `MatrixView.swift`, `CalendarView.swift`, `TodayView.swift`.
|
||||||
|
|
||||||
|
### How to test
|
||||||
|
1. Long-press a task in Matrix and in Calendar → Date → "Pick Date" is present.
|
||||||
|
2. Create/edit a task → reminder options read On time / 5 min / 30 min / 1 hour /
|
||||||
|
1 day / Custom, relative to the event time.
|
||||||
|
3. Custom → Minutes/Hours/Days toggle + count wheel commits the right offset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-24 — App rebrand to "Wenza"
|
||||||
|
|
||||||
|
**Status:** In Progress (display name + copy done; App Store name set in ASC)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Branding
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
The app is being rebranded from "Kisani Cal" to **Wenza**.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- `CFBundleDisplayName` → "Wenza" (app) and "Wenza Widgets" (widget) in
|
||||||
|
`project.yml`; permission prompts and in-app copy (sidebar, Auth, Onboarding,
|
||||||
|
Settings, tutorial, widget description) updated to Wenza.
|
||||||
|
- **Deliberately unchanged** to preserve App Store identity and user data: bundle
|
||||||
|
ID `com.kutesir.KisaniCal`, App Group `group.com.kutesir.KisaniCal`,
|
||||||
|
`kisani.tasks.v2.*` storage keys, team ID, and internal Xcode target/project
|
||||||
|
names.
|
||||||
|
- App Store listing name set separately in App Store Connect (ships with the
|
||||||
|
next version).
|
||||||
|
|
||||||
|
Files: `project.yml`, `ContentView.swift`, `TutorialManager.swift`, `AuthView.swift`,
|
||||||
|
`OnboardingView.swift`, `SettingsView.swift`, `KisaniCalWidgets.swift`.
|
||||||
|
|
||||||
|
### How to test
|
||||||
|
1. Build & run → home-screen label and all in-app titles read "Wenza".
|
||||||
|
2. Confirm tasks/widget data survive (App Group + keys unchanged).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-25 — Matrix "Move" lists only the other quadrants
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Tasks / Matrix
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
The Move submenu listed all four quadrants with a checkmark on the current one,
|
||||||
|
so the quadrant a task already sat in appeared as a no-op option.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- `TaskMenuItems` Move submenu now excludes the task's current quadrant and lists
|
||||||
|
only the other three, using the clear matrix labels (e.g. a task in Urgent &
|
||||||
|
Important shows Not Urgent & Important, Urgent & Unimportant, Not Urgent &
|
||||||
|
Unimportant). Shared across Today, Matrix, and Calendar.
|
||||||
|
|
||||||
|
Files: `TaskContextMenu.swift`.
|
||||||
|
|
||||||
|
### How to test
|
||||||
|
1. Long-press a task in any quadrant → Move shows only the three other quadrants.
|
||||||
|
2. Pick one → task moves (priority/urgency updated per the TickTick model).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KC-26 — Bars widget: auto-select advances to the next event
|
||||||
|
|
||||||
|
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||||
|
**Reported by:** User
|
||||||
|
**Area:** Widgets
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
With "Upcoming queue" on + "Nearest to finish", the Event Countdown (Bars) widget
|
||||||
|
stayed stuck on an event after it expired (e.g. a passed deadline) instead of
|
||||||
|
rolling to the next-nearest. The selection was frozen: the timeline computed the
|
||||||
|
chosen event once and reused it for all 24 pre-rendered entries, only refreshing
|
||||||
|
every 12 hours.
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
- Each timeline entry now re-resolves the nearest-to-finish event *as of that
|
||||||
|
entry's own time* (`upcomingEvents(asOf:)` filters `dueDate > entryDate`), so
|
||||||
|
the moment the current event expires the next entry rolls to the next one.
|
||||||
|
- The timeline refreshes the instant the current selection expires
|
||||||
|
(`currentSelectionExpiry`), not 12 hours later, giving the next event a fresh
|
||||||
|
full pre-render window.
|
||||||
|
- Completing/closing a task already reloads widget timelines, and the recompute
|
||||||
|
drops it and picks the next. Tracked-event and manual-pick paths unchanged.
|
||||||
|
|
||||||
|
Files: `EventCountdownWidget.swift`.
|
||||||
|
|
||||||
|
### How to test
|
||||||
|
1. Queue on + Nearest to finish; let the nearest event's due time pass → widget
|
||||||
|
rolls to the next-nearest without manual action.
|
||||||
|
2. Complete the nearest event → widget advances to the next.
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ class TaskViewModel: ObservableObject {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// All incomplete single-occurrence rows (dated and undated) — for Group & Sort
|
||||||
|
/// and multi-select, which regroup the same task set the date sections draw from.
|
||||||
|
var allActiveRows: [TaskItem] { activeRows() }
|
||||||
|
|
||||||
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
|
/// Active rows whose due date falls in [today+lo, today+hi) days (hi nil = open).
|
||||||
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
|
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
|
||||||
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
|
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
|
||||||
|
|||||||
@@ -20,14 +20,11 @@ struct SplashView: View {
|
|||||||
.scaleEffect(iconScale)
|
.scaleEffect(iconScale)
|
||||||
.opacity(iconOpacity)
|
.opacity(iconOpacity)
|
||||||
|
|
||||||
// kisaniCAL. — single line, orange dot
|
// wenza. — single line, orange dot
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("kisani")
|
Text("wenza")
|
||||||
.font(AppFonts.mono(22, weight: .regular))
|
.font(AppFonts.mono(22, weight: .regular))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
Text("CAL")
|
|
||||||
.font(AppFonts.mono(22, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.text(cs))
|
|
||||||
Text(".")
|
Text(".")
|
||||||
.font(AppFonts.mono(22, weight: .bold))
|
.font(AppFonts.mono(22, weight: .bold))
|
||||||
.foregroundColor(AppColors.accent)
|
.foregroundColor(AppColors.accent)
|
||||||
|
|||||||
@@ -89,9 +89,10 @@ struct TaskMenuItems: View {
|
|||||||
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
|
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
ForEach(Quadrant.allCases) { q in
|
// Only the quadrants the task is NOT currently in — you can't move it to where it sits.
|
||||||
|
ForEach(Quadrant.allCases.filter { $0 != (currentQuadrant ?? task.quadrant) }) { q in
|
||||||
Button { onMove(task, q) } label: {
|
Button { onMove(task, q) } label: {
|
||||||
Label(q.rawValue, systemImage: q == (currentQuadrant ?? task.quadrant) ? "checkmark" : "square")
|
Label(q.matrixLabel, systemImage: "arrow.right")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} label: { Label("Move", systemImage: "arrow.right.square") }
|
} label: { Label("Move", systemImage: "arrow.right.square") }
|
||||||
|
|||||||
323
KisaniCal/Views/TodayMenuFeatures.swift
Normal file
323
KisaniCal/Views/TodayMenuFeatures.swift
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Today background themes
|
||||||
|
|
||||||
|
enum TodayBackground {
|
||||||
|
static let presets: [(id: String, label: String)] = [
|
||||||
|
("default", "Default"),
|
||||||
|
("warm", "Warm"),
|
||||||
|
("cool", "Cool"),
|
||||||
|
("mono", "Mono"),
|
||||||
|
("paper", "Paper"),
|
||||||
|
]
|
||||||
|
|
||||||
|
/// The full-screen background for a given style. "default" is a flat surface;
|
||||||
|
/// the rest are subtle top-down tints that fade into the base background.
|
||||||
|
static func gradient(_ id: String, _ cs: ColorScheme) -> LinearGradient {
|
||||||
|
let base = AppColors.background(cs)
|
||||||
|
let top: Color
|
||||||
|
switch id {
|
||||||
|
case "warm": top = AppColors.accentSoft
|
||||||
|
case "cool": top = AppColors.blueSoft
|
||||||
|
case "mono": top = Color(.systemGray5)
|
||||||
|
case "paper": top = Color(red: 0.96, green: 0.94, blue: 0.89)
|
||||||
|
default: top = base
|
||||||
|
}
|
||||||
|
return LinearGradient(colors: [top, base], startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group & Sort model
|
||||||
|
|
||||||
|
enum TaskGrouping: String, CaseIterable, Identifiable {
|
||||||
|
case date, priority, category, quadrant
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .date: return "Date"
|
||||||
|
case .priority: return "Priority"
|
||||||
|
case .category: return "Category"
|
||||||
|
case .quadrant: return "Quadrant"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskSort: String, CaseIterable, Identifiable {
|
||||||
|
case smart, due, priority, title
|
||||||
|
var id: String { rawValue }
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .smart: return "Smart (pinned, then date)"
|
||||||
|
case .due: return "Due date"
|
||||||
|
case .priority: return "Priority"
|
||||||
|
case .title: return "Title"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TaskOrganizer {
|
||||||
|
private static func priorityRank(_ p: Priority) -> Int {
|
||||||
|
switch p { case .high: return 0; case .medium: return 1; case .low: return 2; case .none: return 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sorted(_ tasks: [TaskItem], by sort: String) -> [TaskItem] {
|
||||||
|
switch sort {
|
||||||
|
case "due":
|
||||||
|
return tasks.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
|
case "priority":
|
||||||
|
return tasks.sorted { priorityRank($0.priority) < priorityRank($1.priority) }
|
||||||
|
case "title":
|
||||||
|
return tasks.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
|
||||||
|
default: // smart
|
||||||
|
return tasks.sorted {
|
||||||
|
if $0.isPinned != $1.isPinned { return $0.isPinned }
|
||||||
|
return ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-date groupings → titled sections, each internally sorted. ("date" is handled
|
||||||
|
/// by the existing day sections, so it isn't produced here.)
|
||||||
|
@MainActor
|
||||||
|
static func sections(_ tasks: [TaskItem], grouping: String, sort: String,
|
||||||
|
vm: TaskViewModel) -> [(title: String, tasks: [TaskItem])] {
|
||||||
|
let s = sorted(tasks, by: sort)
|
||||||
|
switch grouping {
|
||||||
|
case "priority":
|
||||||
|
return [Priority.high, .medium, .low, .none].compactMap { p in
|
||||||
|
let g = s.filter { $0.priority == p }
|
||||||
|
return g.isEmpty ? nil : (p.label, g)
|
||||||
|
}
|
||||||
|
case "category":
|
||||||
|
return [TaskCategory.birthday, .domain, .annual, .reminder, .custom].compactMap { c in
|
||||||
|
let g = s.filter { $0.category == c }
|
||||||
|
return g.isEmpty ? nil : (c.rawValue, g)
|
||||||
|
}
|
||||||
|
case "quadrant":
|
||||||
|
return [Quadrant.urgent, .schedule, .delegate_, .eliminate].compactMap { q in
|
||||||
|
let g = s.filter { vm.displayQuadrant($0) == q }
|
||||||
|
return g.isEmpty ? nil : (q.matrixLabel, g)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return [("Tasks", s)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Background picker sheet
|
||||||
|
|
||||||
|
struct BackgroundPickerSheet: View {
|
||||||
|
@Binding var selected: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var cs
|
||||||
|
|
||||||
|
private let columns = [GridItem(.adaptive(minimum: 96), spacing: 14)]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: columns, spacing: 14) {
|
||||||
|
ForEach(TodayBackground.presets, id: \.id) { preset in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.snappy) { selected = preset.id }
|
||||||
|
} label: {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.fill(TodayBackground.gradient(preset.id, cs))
|
||||||
|
.frame(height: 80)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 14)
|
||||||
|
.stroke(selected == preset.id ? AppColors.accent : AppColors.border(cs),
|
||||||
|
lineWidth: selected == preset.id ? 2.5 : 1)
|
||||||
|
)
|
||||||
|
.overlay(alignment: .topTrailing) {
|
||||||
|
if selected == preset.id {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
.foregroundColor(AppColors.accent)
|
||||||
|
.padding(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(preset.label)
|
||||||
|
.font(AppFonts.sans(12, weight: .medium))
|
||||||
|
.foregroundColor(AppColors.text2(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(18)
|
||||||
|
}
|
||||||
|
.background(AppColors.background(cs).ignoresSafeArea())
|
||||||
|
.navigationTitle("Background")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Group & Sort sheet
|
||||||
|
|
||||||
|
struct GroupSortSheet: View {
|
||||||
|
@Binding var grouping: String
|
||||||
|
@Binding var sort: String
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("Group by") {
|
||||||
|
ForEach(TaskGrouping.allCases) { g in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { grouping = g.rawValue }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(g.label).foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if grouping == g.rawValue {
|
||||||
|
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section("Sort by") {
|
||||||
|
ForEach(TaskSort.allCases) { s in
|
||||||
|
Button {
|
||||||
|
withAnimation(KisaniSpring.micro) { sort = s.rawValue }
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(s.label).foregroundColor(.primary)
|
||||||
|
Spacer()
|
||||||
|
if sort == s.rawValue {
|
||||||
|
Image(systemName: "checkmark").foregroundColor(AppColors.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Group & Sort")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Multi-select sheet
|
||||||
|
|
||||||
|
struct SelectableTaskListSheet: View {
|
||||||
|
@EnvironmentObject var taskVM: TaskViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var cs
|
||||||
|
@State private var selection: Set<UUID> = []
|
||||||
|
|
||||||
|
private let fmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "d MMM"; return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var rows: [TaskItem] { taskVM.allActiveRows }
|
||||||
|
private var selectedTasks: [TaskItem] { rows.filter { selection.contains($0.id) } }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(rows) { task in
|
||||||
|
Button { toggle(task.id) } label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: selection.contains(task.id) ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(selection.contains(task.id) ? AppColors.accent : AppColors.text3(cs))
|
||||||
|
Text(task.title)
|
||||||
|
.font(AppFonts.sans(14))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
if let d = task.dueDate {
|
||||||
|
Text(fmt.string(from: d))
|
||||||
|
.font(AppFonts.mono(10))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle(selection.isEmpty ? "Select" : "\(selection.count) Selected")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button(selection.count == rows.count && !rows.isEmpty ? "Deselect All" : "Select All") { toggleAll() }
|
||||||
|
.disabled(rows.isEmpty)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
if !selection.isEmpty { bulkBar }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bulkBar: some View {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
bulkButton("Complete", "checkmark.circle") {
|
||||||
|
selectedTasks.forEach { taskVM.toggle($0) }
|
||||||
|
selection.removeAll()
|
||||||
|
}
|
||||||
|
Menu {
|
||||||
|
ForEach(Priority.allCases) { p in
|
||||||
|
Button(p.label) { selectedTasks.forEach { taskVM.setPriority($0, priority: p) } }
|
||||||
|
}
|
||||||
|
} label: { bulkLabel("Priority", "flag") }
|
||||||
|
|
||||||
|
Menu {
|
||||||
|
ForEach(Quadrant.allCases) { q in
|
||||||
|
Button(q.matrixLabel) { selectedTasks.forEach { taskVM.moveToQuadrant(taskID: $0.id, quadrant: q) } }
|
||||||
|
}
|
||||||
|
} label: { bulkLabel("Move", "arrow.right.square") }
|
||||||
|
|
||||||
|
bulkButton("Delete", "trash", role: .destructive) {
|
||||||
|
selectedTasks.forEach { taskVM.delete($0) }
|
||||||
|
selection.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
.overlay(Rectangle().frame(height: 1).foregroundColor(AppColors.border(cs)), alignment: .top)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bulkButton(_ title: String, _ icon: String,
|
||||||
|
role: ButtonRole? = nil, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(role: role, action: action) { bulkLabel(title, icon, destructive: role == .destructive) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bulkLabel(_ title: String, _ icon: String, destructive: Bool = false) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: icon).font(.system(size: 18))
|
||||||
|
Text(title).font(AppFonts.sans(10, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundColor(destructive ? .red : AppColors.accent)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggle(_ id: UUID) {
|
||||||
|
if selection.contains(id) { selection.remove(id) } else { selection.insert(id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleAll() {
|
||||||
|
if selection.count == rows.count { selection.removeAll() }
|
||||||
|
else { selection = Set(rows.map { $0.id }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,12 @@ struct TodayView: View {
|
|||||||
@State private var showWorkoutSession = false
|
@State private var showWorkoutSession = false
|
||||||
@State private var editingTask: TaskItem? = nil
|
@State private var editingTask: TaskItem? = nil
|
||||||
@AppStorage("todayHideCompleted") private var hideCompleted = false
|
@AppStorage("todayHideCompleted") private var hideCompleted = false
|
||||||
|
@AppStorage("todayBackground") private var bgStyle = "default"
|
||||||
|
@AppStorage("todayGroupMode") private var groupMode = "date"
|
||||||
|
@AppStorage("todaySortMode") private var sortMode = "smart"
|
||||||
|
@State private var showBackgroundSheet = false
|
||||||
|
@State private var showGroupSortSheet = false
|
||||||
|
@State private var showSelectSheet = false
|
||||||
@ObservedObject private var tm = TutorialManager.shared
|
@ObservedObject private var tm = TutorialManager.shared
|
||||||
@ObservedObject private var hk = HealthKitManager.shared
|
@ObservedObject private var hk = HealthKitManager.shared
|
||||||
|
|
||||||
@@ -27,6 +33,29 @@ struct TodayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group & Sort: non-date groupings rendered as titled sections (reuses UpcomingSection).
|
||||||
|
@ViewBuilder private var groupedTaskSections: some View {
|
||||||
|
let sections = TaskOrganizer.sections(taskVM.allActiveRows, grouping: groupMode,
|
||||||
|
sort: sortMode, vm: taskVM)
|
||||||
|
ForEach(Array(sections.enumerated()), id: \.offset) { _, section in
|
||||||
|
UpcomingSection(
|
||||||
|
title: section.title,
|
||||||
|
tasks: section.tasks,
|
||||||
|
onToggle: { taskVM.toggle($0) },
|
||||||
|
onPostpone: { taskVM.postpone($0) },
|
||||||
|
onPin: { taskVM.pin($0) },
|
||||||
|
onEdit: { editingTask = $0 },
|
||||||
|
onDelete: { taskVM.delete($0) },
|
||||||
|
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||||
|
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||||
|
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||||
|
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||||
|
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||||
|
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
@@ -36,7 +65,7 @@ struct TodayView: View {
|
|||||||
// ── Header ──
|
// ── Header ──
|
||||||
HStack {
|
HStack {
|
||||||
Menu {
|
Menu {
|
||||||
Button { } label: { Label("Background", systemImage: "paintbrush") }
|
Button { showBackgroundSheet = true } label: { Label("Background", systemImage: "paintbrush") }
|
||||||
Button {
|
Button {
|
||||||
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
|
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
|
||||||
} label: {
|
} label: {
|
||||||
@@ -44,8 +73,8 @@ struct TodayView: View {
|
|||||||
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
|
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
|
Button { showGroupSortSheet = true } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
|
||||||
Button { } label: { Label("Select", systemImage: "checkmark.circle") }
|
Button { showSelectSheet = true } label: { Label("Select", systemImage: "checkmark.circle") }
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "line.3.horizontal")
|
Image(systemName: "line.3.horizontal")
|
||||||
.font(.system(size: 13, weight: .medium))
|
.font(.system(size: 13, weight: .medium))
|
||||||
@@ -81,6 +110,7 @@ struct TodayView: View {
|
|||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if groupMode == "date" {
|
||||||
// ── Today timeline: active → completed (green) ──
|
// ── Today timeline: active → completed (green) ──
|
||||||
let activeTasks = taskVM.todayTasks
|
let activeTasks = taskVM.todayTasks
|
||||||
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
|
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
|
||||||
@@ -197,6 +227,10 @@ struct TodayView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
groupedTaskSections
|
||||||
|
}
|
||||||
|
|
||||||
// ── Completed (all, collapsible) ──
|
// ── Completed (all, collapsible) ──
|
||||||
if !taskVM.completedTasks.isEmpty {
|
if !taskVM.completedTasks.isEmpty {
|
||||||
CompletedSection(
|
CompletedSection(
|
||||||
@@ -207,9 +241,7 @@ struct TodayView: View {
|
|||||||
.padding(.top, 10)
|
.padding(.top, 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|
let hasAnything = !taskVM.allActiveRows.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
||||||
|| !taskVM.tomorrowTasks.isEmpty || !taskVM.next7DaysTasks.isEmpty
|
|
||||||
|| !taskVM.laterTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
|
||||||
|| !todayCalEvents.isEmpty
|
|| !todayCalEvents.isEmpty
|
||||||
|| workoutVM.program(for: Date()) != nil
|
|| workoutVM.program(for: Date()) != nil
|
||||||
if !hasAnything {
|
if !hasAnything {
|
||||||
@@ -250,13 +282,28 @@ struct TodayView: View {
|
|||||||
.zIndex(10)
|
.zIndex(10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(AppColors.background(cs).ignoresSafeArea())
|
.background(TodayBackground.gradient(bgStyle, cs).ignoresSafeArea())
|
||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskSheet()
|
AddTaskSheet()
|
||||||
.environmentObject(taskVM)
|
.environmentObject(taskVM)
|
||||||
.presentationDetents([.height(150)])
|
.presentationDetents([.height(150)])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showBackgroundSheet) {
|
||||||
|
BackgroundPickerSheet(selected: $bgStyle)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showGroupSortSheet) {
|
||||||
|
GroupSortSheet(grouping: $groupMode, sort: $sortMode)
|
||||||
|
.presentationDetents([.medium])
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSelectSheet) {
|
||||||
|
SelectableTaskListSheet()
|
||||||
|
.environmentObject(taskVM)
|
||||||
|
.presentationDragIndicator(.visible)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showWorkoutSession) {
|
.sheet(isPresented: $showWorkoutSession) {
|
||||||
WorkoutSessionSheet()
|
WorkoutSessionSheet()
|
||||||
.environmentObject(workoutVM)
|
.environmentObject(workoutVM)
|
||||||
|
|||||||
@@ -209,29 +209,46 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
||||||
let base = entry(for: configuration)
|
let now = Date()
|
||||||
// Live progress: pre-render an entry every 30 minutes for the next 12 hours
|
let step: TimeInterval = 1800 // 30 minutes
|
||||||
// so the bar and time-left label keep moving between refreshes.
|
let count = 24 // ~12-hour pre-render window
|
||||||
let entries: [EventEntry] = (0..<24).map { step in
|
// Re-resolve the selection at EACH step's own time, so the moment the nearest
|
||||||
EventEntry(date: Date().addingTimeInterval(Double(step) * 1800),
|
// event expires the next entry rolls to the next-nearest automatically.
|
||||||
eventName: base.eventName,
|
let entries: [EventEntry] = (0..<count).map { i in
|
||||||
start: base.start,
|
entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
|
||||||
target: base.target,
|
}
|
||||||
unitMode: base.unitMode)
|
// Refresh at the window end, or right when the current selection expires
|
||||||
|
// (whichever is sooner) so the next event gets a fresh full pre-render.
|
||||||
|
let windowEnd = now.addingTimeInterval(Double(count) * step)
|
||||||
|
var next = windowEnd
|
||||||
|
if let expiry = currentSelectionExpiry(for: configuration, asOf: now),
|
||||||
|
expiry > now, expiry < windowEnd {
|
||||||
|
next = expiry.addingTimeInterval(1)
|
||||||
}
|
}
|
||||||
let next = Date().addingTimeInterval(24 * 1800)
|
|
||||||
return Timeline(entries: entries, policy: .after(next))
|
return Timeline(entries: entries, policy: .after(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Upcoming events (incomplete tasks with a future due date), nearest first.
|
/// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*),
|
||||||
private func upcomingEvents() -> [EventEntity] {
|
/// nearest first. Passing `now` lets future timeline steps drop just-expired events.
|
||||||
|
private func upcomingEvents(asOf now: Date) -> [EventEntity] {
|
||||||
loadWidgetTasks()
|
loadWidgetTasks()
|
||||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() }
|
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
|
||||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||||
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
.map { EventEntity(id: $0.id.uuidString, title: $0.title, dueDate: $0.dueDate,
|
||||||
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
isRecurring: $0.isRecurring, recurrenceLabel: $0.recurrenceLabel) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Due date of the event the auto-select queue is currently showing (nil if not in
|
||||||
|
/// queue mode, an event is tracked, or there's nothing upcoming).
|
||||||
|
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
|
||||||
|
guard config.upcomingQueue,
|
||||||
|
UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent") == nil else { return nil }
|
||||||
|
let upcoming = upcomingEvents(asOf: now)
|
||||||
|
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||||
|
guard idx < upcoming.count else { return nil }
|
||||||
|
return upcoming[idx].dueDate
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve the progress window (start → target) for any event. Recurring events
|
/// Resolve the progress window (start → target) for any event. Recurring events
|
||||||
/// (Mode B) span the current occurrence period: previous → next occurrence —
|
/// (Mode B) span the current occurrence period: previous → next occurrence —
|
||||||
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
|
/// so a birthday 5 days away reads ~98% full. One-off events (Mode A) count
|
||||||
@@ -245,7 +262,7 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func entry(for config: EventConfigIntent) -> EventEntry {
|
private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry {
|
||||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||||
|
|
||||||
// An event tracked from the app ("Track Countdown") bypasses the widget
|
// An event tracked from the app ("Track Countdown") bypasses the widget
|
||||||
@@ -257,35 +274,35 @@ struct EventProvider: AppIntentTimelineProvider {
|
|||||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||||
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: task.isRecurring, label: task.recurrenceLabel,
|
||||||
anchorKey: trackedId, explicitStart: since)
|
anchorKey: trackedId, explicitStart: since)
|
||||||
return EventEntry(date: .now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Configured (no tracked event) ──
|
// ── Configured (no tracked event) ──
|
||||||
if config.upcomingQueue {
|
if config.upcomingQueue {
|
||||||
let upcoming = upcomingEvents()
|
let upcoming = upcomingEvents(asOf: now)
|
||||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||||
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
||||||
return EventEntry(date: .now, eventName: "No upcoming event",
|
return EventEntry(date: now, eventName: "No upcoming event",
|
||||||
start: Date(), target: Date().addingTimeInterval(86400), unitMode: mode)
|
start: now, target: now.addingTimeInterval(86400), unitMode: mode)
|
||||||
}
|
}
|
||||||
let ev = upcoming[idx]
|
let ev = upcoming[idx]
|
||||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
anchorKey: ev.id, explicitStart: nil)
|
anchorKey: ev.id, explicitStart: nil)
|
||||||
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
if let ev = config.event, let due = ev.dueDate {
|
if let ev = config.event, let due = ev.dueDate {
|
||||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||||
anchorKey: ev.id, explicitStart: config.startDate)
|
anchorKey: ev.id, explicitStart: config.startDate)
|
||||||
return EventEntry(date: .now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||||
}
|
}
|
||||||
// Fully custom event (no task behind it).
|
// Fully custom event (no task behind it).
|
||||||
let name = config.eventName.isEmpty ? "My Event" : config.eventName
|
let name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||||
let target = config.targetDate
|
let target = config.targetDate
|
||||||
?? Calendar.current.startOfDay(for: Date()).addingTimeInterval(2 * 86400)
|
?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400)
|
||||||
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||||
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
|
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
|
||||||
?? Self.storedAnchor(key: anchorKey, target: target)
|
?? Self.storedAnchor(key: anchorKey, target: target)
|
||||||
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
return EventEntry(date: now, eventName: name, start: start, target: target, unitMode: mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First-seen date for a tracked event, persisted in the App Group so progress
|
/// First-seen date for a tracked event, persisted in the App Group so progress
|
||||||
|
|||||||
Reference in New Issue
Block a user