Compare commits
31 Commits
d09a6c1fe3
...
5cf32a4370
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cf32a4370 | ||
|
|
6a5275b60b | ||
|
|
9e01a7e7b5 | ||
|
|
694db506b6 | ||
|
|
cb0dc3e271 | ||
|
|
bcb66ef691 | ||
|
|
e7e8d07100 | ||
|
|
48e2f4fc10 | ||
|
|
87c999c676 | ||
|
|
81813530c8 | ||
|
|
ac147c55b0 | ||
|
|
4b16e8587d | ||
|
|
fae56cb2a8 | ||
|
|
3592d2a9d8 | ||
|
|
2b48509a35 | ||
|
|
b6eaba78ba | ||
|
|
a7639f6454 | ||
|
|
0606d9c3aa | ||
|
|
49b22b5f2f | ||
|
|
443fe8504f | ||
|
|
f252fc7707 | ||
|
|
0362d586e5 | ||
|
|
063df4ba03 | ||
|
|
a191cebeed | ||
|
|
9768af0017 | ||
|
|
c0eb1ffa23 | ||
|
|
2f1bc5d1c3 | ||
|
|
b4e36db763 | ||
|
|
0870359ae8 | ||
|
|
6817d3e24a | ||
|
|
7b2b61a228 |
@@ -30,6 +30,8 @@
|
||||
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, ); }; };
|
||||
7CEBC340BFA9238D121946AC /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57714B79AFFF60DA90EB86E3 /* Preview Assets.xcassets */; };
|
||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */; };
|
||||
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */; };
|
||||
8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; };
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA3D5289C5F93484E22DEB63 /* TutorialView.swift */; };
|
||||
8DA396DACE99DC4B7B4A460E /* TaskActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEEB07EB89E89383C32ADB34 /* TaskActivityAttributes.swift */; };
|
||||
@@ -80,13 +82,14 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WenzaWatch.entitlements; sourceTree = "<group>"; };
|
||||
42650F655DB8B320C7C03929 /* KisaniCalWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KisaniCalWidgets.swift; sourceTree = "<group>"; };
|
||||
429806CE1021C8DE2EB770CE /* WidgetViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetViews.swift; sourceTree = "<group>"; };
|
||||
449C34805DC6B2CB66886544 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
||||
@@ -98,15 +101,17 @@
|
||||
72308FEE0226F45414C04DDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
72FDF9C8DD37134576356B89 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = "<group>"; };
|
||||
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WenzaWatch.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
89550F2CD19B950CCC6AD37F /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
8DC8687EA2FBA9FB2EEE51C6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
AF905C574F34B4EE51A8D21E /* AppGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppGroup.swift; sourceTree = "<group>"; };
|
||||
B4CFFDFE4653A9E901CEF28D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignTokens.swift; sourceTree = "<group>"; };
|
||||
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashView.swift; sourceTree = "<group>"; };
|
||||
@@ -114,9 +119,11 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WenzaWatchApp.swift; sourceTree = "<group>"; };
|
||||
FF5AFD143B693B77D07FBDA4 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -139,11 +146,22 @@
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
48146B56E740528496663D47 /* WenzaWatch */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4CFFDFE4653A9E901CEF28D /* Info.plist */,
|
||||
35FF274EFF2361B7F3D2D227 /* WenzaWatch.entitlements */,
|
||||
FF528FCC224EF283F95851AD /* WenzaWatchApp.swift */,
|
||||
);
|
||||
path = WenzaWatch;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4D2F2B3472A5A6D99F2FFCD2 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F70DA4746C68CD405435DAB6 /* KisaniCal */,
|
||||
E8D727EEA0C3A4B8006FB087 /* KisaniCalWidgets */,
|
||||
48146B56E740528496663D47 /* WenzaWatch */,
|
||||
51BD1B5DEDE9FAD9CA2FF6DA /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@@ -153,6 +171,7 @@
|
||||
children = (
|
||||
9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */,
|
||||
0D6D6740721F01A74E404EB9 /* KisaniCalWidgets.appex */,
|
||||
7A9D47B284FD6A217AEF813B /* WenzaWatch.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -169,6 +188,7 @@
|
||||
72308FEE0226F45414C04DDD /* SettingsView.swift */,
|
||||
BE2D50B4B4AC227BD21F4B60 /* SplashView.swift */,
|
||||
670A8C6F8243EC973A1BC431 /* TaskContextMenu.swift */,
|
||||
ED5DB5340BEB3EF7A78CA153 /* TodayMenuFeatures.swift */,
|
||||
C786EBC7DF879D64EB28165E /* TodayView.swift */,
|
||||
FA3D5289C5F93484E22DEB63 /* TutorialView.swift */,
|
||||
20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */,
|
||||
@@ -282,6 +302,23 @@
|
||||
productReference = 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
403D989BDC6268361CFFB479 /* WenzaWatch */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 6F608724A2EAA63171766586 /* Build configuration list for PBXNativeTarget "WenzaWatch" */;
|
||||
buildPhases = (
|
||||
6DEFD550940764CA28D993CE /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = WenzaWatch;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = WenzaWatch;
|
||||
productReference = 7A9D47B284FD6A217AEF813B /* WenzaWatch.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -289,7 +326,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2620;
|
||||
LastUpgradeCheck = 1500;
|
||||
TargetAttributes = {
|
||||
28B43516AD88946E21D9BFE0 = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
@@ -299,6 +336,10 @@
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
403D989BDC6268361CFFB479 = {
|
||||
DevelopmentTeam = K8BLMMR883;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EF1C4F3BDA4AA7E008FC2CDE /* Build configuration list for PBXProject "KisaniCal" */;
|
||||
@@ -317,6 +358,7 @@
|
||||
targets = (
|
||||
297C0C2BFBA10AB52D5D49CE /* KisaniCal */,
|
||||
28B43516AD88946E21D9BFE0 /* KisaniCalWidgets */,
|
||||
403D989BDC6268361CFFB479 /* WenzaWatch */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@@ -336,6 +378,14 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
6DEFD550940764CA28D993CE /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8387093D19FB3397CCB8FEF8 /* WenzaWatchApp.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
82EA49FE155821D424C46912 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -364,6 +414,7 @@
|
||||
32C63D81925FBFE51CAE1FB7 /* TaskActivityAttributes.swift in Sources */,
|
||||
EE7BCEB45B1F0B91C9D3C1D2 /* TaskContextMenu.swift in Sources */,
|
||||
096804560A2F0A7D74E64780 /* TaskItem.swift in Sources */,
|
||||
83EA218392952885C97144D1 /* TodayMenuFeatures.swift in Sources */,
|
||||
C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */,
|
||||
3AD7F62D231DCFFFFCB31649 /* TutorialManager.swift in Sources */,
|
||||
8C9567A3DE3F63A1ECAE84D5 /* TutorialView.swift in Sources */,
|
||||
@@ -407,11 +458,11 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
@@ -423,7 +474,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -465,12 +515,11 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
@@ -480,12 +529,10 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -501,12 +548,7 @@
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -548,12 +590,11 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 9;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = K8BLMMR883;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
@@ -569,19 +610,35 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
D3A1329FCB6BBA1E8CC25FFC /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = WenzaWatch/WenzaWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WenzaWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.watchkitapp;
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
DF56CDD73E9B177D57AE17FB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -591,12 +648,7 @@
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = KisaniCalWidgets/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.KisaniCalWidgets;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -604,6 +656,24 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F00C7E0C784B90820717D614 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = WenzaWatch/WenzaWatch.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = WenzaWatch/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal.watchkitapp;
|
||||
SDKROOT = watchos;
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_VERSION = 5.9;
|
||||
TARGETED_DEVICE_FAMILY = 4;
|
||||
WATCHOS_DEPLOYMENT_TARGET = 10.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F364512BEB70ECB7CB83FBFE /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -614,11 +684,11 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal";
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Kisani Cal saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Wenza;
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription = "Wenza shows your calendar events alongside tasks.";
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription = "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard.";
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Wenza saves workouts you complete to the Health app.";
|
||||
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen;
|
||||
@@ -630,7 +700,6 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_VERSION = 5.9;
|
||||
@@ -650,6 +719,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
6F608724A2EAA63171766586 /* Build configuration list for PBXNativeTarget "WenzaWatch" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F00C7E0C784B90820717D614 /* Debug */,
|
||||
D3A1329FCB6BBA1E8CC25FFC /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
B60FDD2C88D1D33CF1EC02B3 /* Build configuration list for PBXNativeTarget "KisaniCal" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "1500"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
buildImplicitDependencies = "YES"
|
||||
runPostActionsOnFailure = "NO">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
@@ -26,7 +27,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||
<MacroExpansion>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
@@ -38,6 +40,8 @@
|
||||
</MacroExpansion>
|
||||
<Testables>
|
||||
</Testables>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -59,6 +63,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
@@ -76,6 +82,8 @@
|
||||
ReferencedContainer = "container:KisaniCal.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<CommandLineArguments>
|
||||
</CommandLineArguments>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
|
||||
@@ -94,6 +94,7 @@ struct ContentView: View {
|
||||
.onAppear {
|
||||
if !hasOnboarded { showOnboarding = true }
|
||||
workoutVM.resetSetsForNewDayIfNeeded()
|
||||
workoutVM.checkPendingMissed()
|
||||
CloudSyncManager.shared.restoreSettings()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
HealthKitManager.shared.bootstrap()
|
||||
@@ -111,6 +112,7 @@ struct ContentView: View {
|
||||
consumeWidgetAddTask()
|
||||
workoutVM.resetSetsForNewDayIfNeeded()
|
||||
workoutVM.checkPendingHealthConfirm()
|
||||
workoutVM.checkPendingMissed()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
Task {
|
||||
@@ -123,6 +125,12 @@ struct ContentView: View {
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
.sheet(item: $workoutVM.askCompensate) { missed in
|
||||
CompensationSheet(missed: missed)
|
||||
.environmentObject(workoutVM)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.onChange(of: workoutVM.doneSets) { _ in
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
}
|
||||
@@ -222,7 +230,7 @@ struct ContentView: View {
|
||||
if showWorkoutTab { Label("Workout", systemImage: "dumbbell").tag(3) }
|
||||
Label("Settings", systemImage: "gearshape").tag(4)
|
||||
}
|
||||
.navigationTitle("Kisani Cal")
|
||||
.navigationTitle("Wenza")
|
||||
.listStyle(.sidebar)
|
||||
} detail: {
|
||||
switch iPadSelection ?? 0 {
|
||||
|
||||
@@ -339,3 +339,683 @@ leaked onto all of those dates.
|
||||
finished. Future occurrences are never auto-completed.
|
||||
|
||||
Files: `ExerciseModels.swift`, `CalendarView.swift`, `TodayView.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-11 — Calendar appeared capped at 2026 (no year shown)
|
||||
|
||||
**Status:** Resolved
|
||||
**Reported by:** User
|
||||
**Area:** Calendar
|
||||
|
||||
### Description
|
||||
Users couldn't navigate/see dates beyond 2026.
|
||||
|
||||
### Root cause
|
||||
Navigation was actually unbounded, but every header showed only the month name
|
||||
(`"MMMM"`) — the Year view had no year label at all — so crossing a year boundary
|
||||
was invisible and future months felt unreachable (compounded by empty future
|
||||
months from the missing recurrence engine, KC-12).
|
||||
|
||||
### Fix
|
||||
Show the year: main header `"Month Year"`, date-picker grid `"MMMM yyyy"`, and a
|
||||
`◀ year ▶` navigator + label in the Year view (`navigateYear`).
|
||||
Files: `CalendarView.swift`, `TodayView.swift`. Committed in `7b2b61a`.
|
||||
|
||||
---
|
||||
|
||||
## KC-12 — Recurring tasks don't appear on future dates
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Calendar
|
||||
|
||||
### Description
|
||||
A task set to repeat (Daily/Weekly/Monthly/Yearly/Every-Weekday) only ever showed
|
||||
on its single original `dueDate`. No future occurrences, no per-occurrence
|
||||
completion.
|
||||
|
||||
### Root cause
|
||||
There was no recurrence engine — tasks carry `isRecurring` + `recurrenceLabel` but
|
||||
a single `dueDate`, and every surface matched by `isDate(dueDate, inSameDayAs:)`.
|
||||
|
||||
### Fix (core)
|
||||
- Model: `recurrenceEnd` + `completedOccurrences` (optional → safe decode of old data).
|
||||
- `TaskViewModel` engine: `isOccurrence(_:on:)`, `occurrenceComplete`,
|
||||
`toggleOccurrence`, `occurrenceCopy`, `nextOccurrence`, `occurrences(on:)`.
|
||||
- `toggle(_:)` routes recurring rows to per-occurrence completion (one day never
|
||||
affects another).
|
||||
- Date filters (`today`/`next3`/`upcoming`/`completedToday`) exclude recurring
|
||||
masters and re-inject per-date occurrence copies; recurring never goes "overdue".
|
||||
- Calendar dots + day detail use `occurrences(on:)`.
|
||||
- Works across year boundaries; respects `recurrenceEnd` when set.
|
||||
|
||||
Files: `TaskItem.swift`, `CalendarView.swift`.
|
||||
|
||||
### "Repeat until" UI (done)
|
||||
- Added `recurrenceEnd` through `NLParsed`, `TaskDatePickerSheet`, both add/edit
|
||||
sheets, and `addTask`/`updateTask`.
|
||||
- Date picker shows a "Repeat until" row (graphical date picker, default "Forever",
|
||||
with Clear) whenever a recurrence is selected.
|
||||
|
||||
Files: `TaskItem.swift`, `CalendarView.swift`, `TodayView.swift`, `MatrixView.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-13 — Recurrence redesign: single occurrence, new sections, classic Matrix
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Calendar / Matrix
|
||||
|
||||
### Description
|
||||
The first recurrence pass used calendar-expansion in lists — a daily task showed
|
||||
in Today, Tomorrow, Next 7 Days, and Upcoming simultaneously (planning noise).
|
||||
Should behave like TickTick: one task, one next occurrence, one source of truth.
|
||||
|
||||
### Fix
|
||||
- **Single active occurrence (P1/P5):** `nextActiveOccurrence` + `activeRows` — each
|
||||
recurring task contributes ONE row (its next un-completed occurrence). Completing
|
||||
rolls forward; no future duplicates in lists. Calendar grid still shows per-date.
|
||||
- **Sections (P2):** replaced Today / Next 3 Days / Upcoming with
|
||||
**Today / Tomorrow / Next 7 Days / Later** (`todayTasks`/`tomorrowTasks`/
|
||||
`next7DaysTasks`/`laterTasks`; `UpcomingSection` gained a `title`).
|
||||
- **Icons (P3):** ⏰ reminder + 🔁 recurring on the right of each row; fixed the
|
||||
hardcoded "Annual" label to show the real frequency.
|
||||
- **Matrix (P4):** classic Eisenhower — importance is user-assigned (row), urgency is
|
||||
derived from the deadline (within `urgentWindowDays` = 7). `displayQuadrant` +
|
||||
`matrixTasks` group by the computed quadrant; recurring rolls forward. Chosen over
|
||||
the fully-auto "far = Q4" model so the user keeps control of importance.
|
||||
|
||||
Files: `TaskItem.swift`, `TodayView.swift`, `MatrixView.swift`.
|
||||
Commits: `b4e36db` (lists + icons), `2f1bc5d` (Matrix).
|
||||
|
||||
---
|
||||
|
||||
## KC-14 — Workout Stats (daily / weekly / monthly performance)
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Workout
|
||||
|
||||
### Description
|
||||
Per-day logs (KC-9) existed but weren't aggregated, so there was no way to compare
|
||||
daily/weekly/monthly performance.
|
||||
|
||||
### Fix
|
||||
- `WorkoutDayLog.volume` (Σ weight×reps over done sets); `StatPeriod`/`WorkoutStat`/
|
||||
`StatBucket`.
|
||||
- `WorkoutViewModel`: `stat(period, offset:)` (current vs previous), `statInterval`,
|
||||
`statBuckets(period, count:)` for charting.
|
||||
- New `WorkoutStatsView` (chart-bar button in the Workout header): Day/Week/Month
|
||||
selector, summary cards (Workouts / Sets / Volume) with %-delta vs the previous
|
||||
period, a metric chooser, and a Swift Charts bar trend (last 14 days / 8 weeks /
|
||||
6 months). Empty state when no history.
|
||||
|
||||
Files: `ExerciseModels.swift`, `WorkoutView.swift`.
|
||||
|
||||
### Note
|
||||
Stats are computed from the app's own per-day logs. Health (steps/kcal/resting HR +
|
||||
workout dates) is already read on the Today dashboard and merged into the streak;
|
||||
a Health overlay on this screen could be added later.
|
||||
|
||||
---
|
||||
|
||||
## KC-15 — Bars countdown widget + unified slate/orange widget theme
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets
|
||||
|
||||
### Description
|
||||
Add a bar-meter event-countdown widget (per reference screenshot) and re-theme
|
||||
every widget to one look: dark slate background + brand orange, matching the app.
|
||||
|
||||
### Fix
|
||||
- `WidgetTheme` (slate background `RGB(0.224,0.255,0.31)`, brand orange accent).
|
||||
- `BarGrid` vertical-bar progress view; new `EventBarsView` + `EventBarsWidget`
|
||||
("Event Countdown (Bars)", medium) reusing the existing event config/provider.
|
||||
- `EventEntry.fineTimeLeft` (days → "X hr, Y min left") for the bars label.
|
||||
- Re-themed all home-screen widgets to slate bg + orange: Day Progress, Tasks Done
|
||||
Today, Event Countdown (dots), My Tasks. Lock-screen accessory widgets stay on
|
||||
`.thinMaterial` (system-tinted on the Lock Screen).
|
||||
|
||||
Files: `WidgetViews.swift`, `EventCountdownWidget.swift`, `MyTasksWidget.swift`,
|
||||
`KisaniCalWidgets.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-16 — Workout check-in, missed state, and rest-day compensation
|
||||
|
||||
**Status:** In Progress (implemented & build-verified; notification actions need a real device)
|
||||
**Reported by:** User
|
||||
**Area:** Workout / Notifications
|
||||
|
||||
### Description
|
||||
Workout reminders should ask "Have you done this workout?" with Yes / No / Snooze.
|
||||
Saying No marks that day missed and offers to compensate on a rest day. Long-press
|
||||
on workout/exercise cards gives quick Done / Not Done / Compensate actions. Each
|
||||
scheduled day keeps its own completion state.
|
||||
|
||||
### Fix
|
||||
- **VM:** `missedDates` + `compensations` (persisted + iCloud), `workoutStatus(on:)`
|
||||
(pending/done/missed), `markWorkoutDone/markWorkoutMissed(on:)` (per-date only),
|
||||
`upcomingRestDays`, `scheduleCompensation`, `promptCompensation`,
|
||||
`checkPendingMissed`. `program(for:)` honors compensations; a compensation day
|
||||
is not a rest day.
|
||||
- **Notifications:** WORKOUT_CONFIRM category now has "Yes, I completed it ✓",
|
||||
"No, I didn't" (→ marks missed + asks compensation on next open), and "Remind me
|
||||
in 1 hour" (snooze re-delivers). Daily reminder + check-in both use it.
|
||||
Compensation days get their own one-off reminder ("Missed workout recovery").
|
||||
- **UI:** `CompensationSheet` (rest-day picker, "No, keep as missed"); long-press
|
||||
menus on the calendar/Today workout row (Done / Not Done / Compensate) and on
|
||||
exercise cards (Mark as Done / Not Done); workout row subtitle shows
|
||||
Pending / Done / Missed and a "Compensation ·" prefix (missed shows accent color).
|
||||
- Daily per-date rules unchanged (KC-1/KC-10): completing Tuesday never completes
|
||||
Saturday.
|
||||
|
||||
Files: `ExerciseModels.swift`, `NotificationManager.swift`, `ContentView.swift`,
|
||||
`WorkoutView.swift`, `CalendarView.swift`, `TodayView.swift`.
|
||||
|
||||
### How to test (device)
|
||||
1. Enable workout reminder/check-in in Settings; when it fires, use the actions.
|
||||
2. "No, I didn't" → open app → compensation sheet appears → pick a rest day →
|
||||
that day now shows the program labeled Compensation (and gets a reminder).
|
||||
3. Long-press the workout card in Today/Calendar → Done / Not Done / Compensate.
|
||||
4. Long-press an exercise card in Workout → Mark as Done / Not Done.
|
||||
|
||||
---
|
||||
|
||||
## KC-17 — Remove Event Countdown (dots) + Tasks Done Today widgets
|
||||
|
||||
**Status:** Resolved (build-verified)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets
|
||||
|
||||
### Description
|
||||
The dot-grid "Event Countdown" and "Tasks Done Today" widgets were judged not up
|
||||
to standard and removed entirely from the gallery.
|
||||
|
||||
### Removal
|
||||
- `EventCountdownWidget` struct (kind `KisaniEventCountdown`) + `EventCountdownView`.
|
||||
- `TasksCompleteWidget` (kind `KisaniTasksComplete`) + `TasksCompleteView` +
|
||||
its only consumer `todayTaskCompletion()` in WidgetData.swift.
|
||||
- Both deregistered from `KisaniWidgetBundle`.
|
||||
- Kept (shared by the surviving bars widget): `EventConfigIntent`, `EventEntity`,
|
||||
`EventQuery`, `EventProvider`, `EventEntry`, `CycleCountdownUnitIntent`,
|
||||
`BarGrid`; `DotGrid` kept (used by Day Progress).
|
||||
- Untouched: Event Countdown (Bars), My Tasks, Day Progress, Lock Screen rect +
|
||||
circular, Task Live Activity.
|
||||
|
||||
Files modified: `KisaniCalWidgets.swift`, `EventCountdownWidget.swift`,
|
||||
`WidgetViews.swift`, `WidgetData.swift`. No files deleted (shared code remains
|
||||
in EventCountdownWidget.swift). No app-side settings referenced these widgets.
|
||||
|
||||
---
|
||||
|
||||
## KC-18 — Bars widget: progress bar never fills; date pickers felt broken
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets
|
||||
|
||||
### Symptoms
|
||||
The Event Countdown (Bars) widget showed all bars dim regardless of time left,
|
||||
and the Event date / Counting from rows in the config appeared not to work.
|
||||
|
||||
### Root causes
|
||||
1. Auto (upcoming-queue) mode anchored progress to `Date()` on **every** refresh,
|
||||
so elapsed time was always ~0 → empty bar forever.
|
||||
2. Custom mode defaulted both `Event date` and `Counting from` to the same
|
||||
instant (`Date.now` at config time) → zero-length span → progress 0. The raw
|
||||
second-precision defaults also made the picker rows look broken.
|
||||
3. The timeline only refreshed at midnight, so even a correct bar wouldn't move
|
||||
during an "X hr left" countdown.
|
||||
|
||||
### Fix
|
||||
- Persistent per-event anchor (`kisani.widget.anchor.<id>` in the App Group):
|
||||
progress fills from when an event was first tracked and resets naturally when
|
||||
the tracked event changes. An explicit "Counting from" before the event wins.
|
||||
- `targetDate`/`startDate` are now optional — rows read "Choose" until set, and a
|
||||
missing custom date falls back to a 2-day stub window.
|
||||
- Timeline pre-renders an entry every 30 minutes (12 h horizon) so the bar and
|
||||
time-left label keep moving.
|
||||
|
||||
Files: `KisaniCalWidgets/EventCountdownWidget.swift`.
|
||||
Note: existing widget instances re-read their config; dates must be re-picked
|
||||
once (params changed to optional).
|
||||
|
||||
---
|
||||
|
||||
## KC-19 — Widgets: exact logo palette everywhere
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets
|
||||
|
||||
### Description
|
||||
All widgets should match the brand orange theme of the logo exactly.
|
||||
|
||||
### Fix
|
||||
- `WidgetTheme` now uses the precise brand palette: accent **RGB(232,98,42)**
|
||||
(app `AppColors.accent`) on the logo's dark **RGB(26,29,34)** background —
|
||||
previously a near-miss orange on a slate background.
|
||||
- Every themed widget (Event Countdown Bars, My Tasks, Day Progress) inherits it.
|
||||
- Task Live Activity: hardcoded accent → `WidgetTheme.accent`; background tint →
|
||||
logo dark.
|
||||
- Lock-screen ring tints → brand accent (renders in color on StandBy/home).
|
||||
|
||||
Files: `WidgetViews.swift`, `TaskLiveActivity.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-20 — "Track Countdown": zero-config bars widget driven from the app
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device build)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets / Tasks
|
||||
|
||||
### Description
|
||||
The bars widget should not need manual event entry: track any KisaniCal event
|
||||
from the app and the widget displays it automatically (title, date, time left,
|
||||
progress bars).
|
||||
|
||||
### Fix
|
||||
- **App:** new `CountdownTracker` + a "Track Countdown" / "Stop Tracking
|
||||
Countdown" item in the shared task long-press menu. Stores
|
||||
`kisani.widget.trackedEvent` (+ `trackedSince`) in the App Group and reloads
|
||||
widget timelines.
|
||||
- **Widget:** the tracked event bypasses the widget configuration entirely.
|
||||
- **Mode A** (one-off): progress = elapsed/(event − tracking date).
|
||||
- **Mode B** (recurring birthdays/anniversaries): progress across the
|
||||
occurrence span containing now (previous → next occurrence), derived from
|
||||
the task's recurrence rule (`recurrenceSpan`).
|
||||
- `WidgetTask` now decodes `isRecurring`/`recurrenceLabel` from the shared JSON.
|
||||
- No tracked event → existing configuration (upcoming queue / picked / custom)
|
||||
remains the fallback, exactly as before.
|
||||
- Bar rendering already maps progress → filled (accent) vs remaining (dimmed)
|
||||
bars; the screenshot showing static dim bars was the pre-KC-18 build.
|
||||
|
||||
Files: `TaskContextMenu.swift`, `WidgetData.swift`, `EventCountdownWidget.swift`.
|
||||
|
||||
### How to test (device)
|
||||
1. Long-press any task with a due date → "Track Countdown".
|
||||
2. The bars widget switches to that event without touching its config.
|
||||
3. A yearly birthday shows Mode B: bar spans last year's → this year's date.
|
||||
4. Long-press the task again → "Stop Tracking Countdown" → widget falls back
|
||||
to its configured mode.
|
||||
|
||||
---
|
||||
|
||||
## KC-21 — Eisenhower Matrix as a TickTick-style view (Priority × deadline)
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Matrix
|
||||
|
||||
### Problem
|
||||
The Matrix treated importance as the task's *stored quadrant* — you placed a
|
||||
task in a row and only a manual "Move" changed it. TickTick instead treats the
|
||||
Matrix as a **view**: it auto-places every task from its **Priority** and **due
|
||||
date**, while still allowing quick drag/menu moves.
|
||||
|
||||
### Fix (model — `TaskItem.swift`)
|
||||
- Replaced whole-quadrant override with `urgencyOverride: Bool?`.
|
||||
- `nil` = urgency is computed from due date.
|
||||
- `true` = force urgent column.
|
||||
- `false` = force not-urgent column.
|
||||
- Importance is now `priority == .high` (top row), not the stored quadrant.
|
||||
Medium/Low/None stay in the bottom row.
|
||||
- Urgency is deadline-driven:
|
||||
- default urgent window = 3 days
|
||||
- birthday/domain/annual/renewal/subscription/exam/deadline = 14 days
|
||||
Auto-placement: Q1 = High+urgent, Q2 = High+later, Q3 = not-High+urgent,
|
||||
Q4 = the rest.
|
||||
- `moveToQuadrant` updates real attributes:
|
||||
- vertical movement sets Priority (top → High; moving down demotes High → Medium)
|
||||
- horizontal movement sets `urgencyOverride` only if the target column disagrees
|
||||
with date-derived urgency.
|
||||
- Editing due date (`setDate`) or saving the editor (`updateTask`) clears
|
||||
`urgencyOverride` so the task re-places live.
|
||||
- Category changes, priority changes, and date changes refresh the legacy
|
||||
`quadrant` color/widget fallback to match computed placement.
|
||||
- `addTask` applies KisaniCal defaults when no priority is picked: birthday /
|
||||
domain / annual (+ title keywords exam, subscription, renew, expir) → High;
|
||||
workout / gym / exercise → Medium.
|
||||
|
||||
### Fix (create UI — `TodayView.swift`)
|
||||
- `AddTaskSheet` no longer has a manual **quadrant** selector. It now exposes a
|
||||
**Priority** menu (flag icon). User picks Priority + Date only; placement is
|
||||
computed. Adding from a Matrix quadrant seeds High for the top row.
|
||||
- Fixed no-date creation: a task without an explicit parsed/picked date stays
|
||||
"No Date" instead of silently becoming today/urgent.
|
||||
|
||||
### Fix (Matrix UI — `MatrixView.swift`, `TaskContextMenu.swift`)
|
||||
- Completed tasks are hidden from the 2×2 Matrix grid by default, but remain in
|
||||
the per-quadrant detail Completed section.
|
||||
- Rows with an urgency override show a small **Manual** badge.
|
||||
- Context menu includes **Reset Matrix Urgency** when an urgency override exists.
|
||||
- Move menu shows all four quadrants, with a checkmark on the current quadrant.
|
||||
|
||||
### How to test
|
||||
1. New task, no priority, far date → lands in Q4; raise to High → jumps to Q2.
|
||||
2. New task with no date → stays in right column until given a due date/reminder.
|
||||
3. Drag Q2 → Q1 when due date is far out → Manual badge appears.
|
||||
4. Reset Matrix Urgency → task returns to its date-derived column.
|
||||
5. Drag Q1 → Q4 → High demotes to Medium and urgency is overridden if needed.
|
||||
6. Open the task, change its due date → urgency override clears, re-places by rule.
|
||||
7. Add a "Mum's birthday" with no priority → auto-High and uses 14-day urgency window.
|
||||
|
||||
---
|
||||
|
||||
## KC-22 — Quick postpone / snooze from task menus and lock screen
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Calendar / Matrix / Notifications
|
||||
|
||||
### Problem
|
||||
Long-pressing a task/event in Today, Matrix, or Calendar required going into the
|
||||
date editor or using only coarse "+1 day" actions. Lock-screen task reminders
|
||||
also only supported "Mark Complete". The user wanted standard task-manager
|
||||
snooze choices directly from the pressed item and notification actions.
|
||||
|
||||
### Fix (app task menus)
|
||||
- Added a shared **Postpone** submenu to `TaskMenuItems`:
|
||||
- Snooze 15 Minutes
|
||||
- Snooze 30 Minutes
|
||||
- Snooze 1 Hour
|
||||
- Snooze 2 Hours
|
||||
- Tomorrow
|
||||
- Wired the submenu through:
|
||||
- Today timeline + overdue/upcoming sections
|
||||
- Matrix grid + quadrant detail sections
|
||||
- Calendar day timeline
|
||||
- `TaskViewModel.postpone(_:minutes:)` applies the nudge:
|
||||
- existing reminder → reminder moves forward
|
||||
- due date with no reminder → due date/time moves forward and becomes timed
|
||||
- no date/reminder → creates a reminder
|
||||
|
||||
### Fix (lock screen notifications)
|
||||
- Task reminder notifications now include actions:
|
||||
- Snooze 15 min
|
||||
- Snooze 30 min
|
||||
- Snooze 1 hour
|
||||
- Snooze 2 hours
|
||||
- Notification response handler updates the stored task reminder and schedules
|
||||
a one-off snoozed notification for the selected delay.
|
||||
|
||||
Files: `TaskContextMenu.swift`, `TaskItem.swift`, `TodayView.swift`,
|
||||
`MatrixView.swift`, `CalendarView.swift`, `NotificationManager.swift`.
|
||||
|
||||
### How to test
|
||||
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.
|
||||
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
|
||||
lock-screen actions appear.
|
||||
5. Tap a lock-screen snooze action → task reminder updates and notification
|
||||
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.
|
||||
|
||||
### Follow-up — tracked events now advance too
|
||||
The first pass left the **tracked** ("Track Countdown") path pinned: a completed
|
||||
or expired tracked event stayed on screen. Fixed with `trackedTask(asOf:)`, which
|
||||
returns the tracked event only while it's live (not complete, and recurring or
|
||||
still future); once done/past (non-recurring) the widget falls through to the
|
||||
next-nearest. Recurring tracked events still roll forward, and the refresh
|
||||
boundary now includes the tracked event's expiry.
|
||||
|
||||
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.
|
||||
3. Track an event, then complete it → widget advances to the next-nearest.
|
||||
|
||||
---
|
||||
|
||||
## KC-27 — Today ⋯ menu: Background, Group & Sort, Select
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Tasks / Today
|
||||
|
||||
### Problem
|
||||
Three items in the Today ⋯ menu were empty placeholders: Background, Group &
|
||||
Sort, and Select.
|
||||
|
||||
### Fix
|
||||
- **Background:** sheet with 5 themes (Default/Warm/Cool/Mono/Paper) applied as a
|
||||
subtle gradient to the Today background; persisted via `@AppStorage`.
|
||||
- **Group & Sort:** group by Date (default)/Priority/Category/Quadrant and sort by
|
||||
Smart/Due/Priority/Title. Non-date groupings render as titled sections reusing
|
||||
`UpcomingSection`.
|
||||
- **Select:** multi-select sheet with Select All / Deselect All and a bulk action
|
||||
bar (Complete / Priority / Move / Delete).
|
||||
- New file `TodayMenuFeatures.swift`; added `TaskViewModel.allActiveRows` so
|
||||
grouping/selection use the same single-occurrence task set (incl. undated).
|
||||
|
||||
Files: `TodayMenuFeatures.swift`, `TodayView.swift`, `TaskItem.swift`.
|
||||
|
||||
### How to test
|
||||
1. ⋯ → Background → pick a theme; Today background updates and persists.
|
||||
2. ⋯ → Group & Sort → Priority/Category/Quadrant regroups the list.
|
||||
3. ⋯ → Select → Select All → Complete marks all done.
|
||||
|
||||
---
|
||||
|
||||
## KC-28 — Clearer Snooze labels on notifications and task menus
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Notifications / Tasks
|
||||
|
||||
### Problem
|
||||
The lock-screen reminder actions and the in-app Postpone submenu showed bare
|
||||
durations ("15 min", "30 min", …) that didn't say what they do. iOS notification
|
||||
buttons are a flat list and can't nest, so a single expanding "Snooze" isn't
|
||||
possible — the labels must carry the meaning.
|
||||
|
||||
### Fix
|
||||
- Lock-screen actions: "Snooze 15 min / 30 min / 1 hour / 2 hours" (+ Mark Complete).
|
||||
- In-app Postpone submenu: "Snooze 15 Minutes / 30 Minutes / 1 Hour / 2 Hours".
|
||||
|
||||
Files: `NotificationManager.swift`, `TaskContextMenu.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-29 — Lock-screen Progress Dots widget legibility
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Widgets
|
||||
|
||||
### Problem
|
||||
The Progress Dots widget in the lock-screen accessoryRectangular family packed
|
||||
100 tiny dots into a small pill over a custom dark background, which the lock
|
||||
screen's vibrant rendering washed out — it looked faint / not loaded.
|
||||
|
||||
### Fix
|
||||
- accessoryRectangular: 28 larger dots (vs 100), `.primary` color for the vibrant
|
||||
tint, and `AccessoryWidgetBackground()` for the standard frosted pill. Home
|
||||
Screen small/medium/large keep the 100-dot orange-on-dark grid.
|
||||
|
||||
Files: `WidgetViews.swift`.
|
||||
|
||||
---
|
||||
|
||||
## KC-30 — Period milestone notifications (day / week / month / year)
|
||||
|
||||
**Status:** In Progress (implemented & build-verified, pending device QA)
|
||||
**Reported by:** User
|
||||
**Area:** Notifications
|
||||
|
||||
### Problem
|
||||
The user wanted recurring "one more period passed" nudges (Pretty Progress
|
||||
style): day, week, month, and year, all at midnight.
|
||||
|
||||
### Fix
|
||||
- `schedulePeriodMilestones()` schedules recurring `UNCalendarNotificationTrigger`s,
|
||||
all firing at **midnight (00:00)**:
|
||||
- Today — "One more day passed." — every day
|
||||
- This Week — "One more week passed." — Monday 00:00 (weekly)
|
||||
- This Month — "One more month passed." — 1st at 00:00 (monthly)
|
||||
- This Year — "One more year passed." — Jan 1 at 00:00 (yearly)
|
||||
- Fixed identifiers + remove-then-add keep them de-duplicated; runs via the
|
||||
existing `reschedule(...)` path when authorized; gated by the
|
||||
`kisani.periodMilestones` flag (default on).
|
||||
- **Pending:** no in-app Settings toggle yet (see KC-31 #2).
|
||||
|
||||
Files: `NotificationManager.swift`.
|
||||
|
||||
### How to test
|
||||
1. Authorize notifications; at midnight a "One more day passed." notification
|
||||
fires (and the week/month/year equivalents on their boundaries).
|
||||
|
||||
---
|
||||
|
||||
## KC-31 — Audit fixes: lock-screen sync, live urgency, batch postpone
|
||||
|
||||
**Status:** Fixed items build-verified; pending items remain open (see below)
|
||||
**Reported by:** User (code review)
|
||||
**Area:** Notifications / Tasks / Matrix
|
||||
|
||||
### Reviewed items & outcomes
|
||||
|
||||
**Fixed**
|
||||
- **Lock-screen Mark Complete / Snooze now sync + reload.** `markTaskComplete`
|
||||
and `snoozeTask` previously wrote only to `UserDefaults.kisani`. They now also
|
||||
`CloudSyncManager.shared.push(...)` (→ iCloud / Apple Watch) and
|
||||
`WidgetCenter.shared.reloadAllTimelines()`.
|
||||
- **Notification copy uses live Matrix urgency, not stored quadrant.**
|
||||
`reschedule(...)` snapshots urgent task IDs from the computed `displayQuadrant`
|
||||
and threads them into `scheduleTasks` / `notifBody`, so "Urgent — tap to mark
|
||||
complete" and the evening check-in reflect current urgency rather than a stale
|
||||
stored fallback.
|
||||
- **Batch `postponeAllOverdue` matches single postpone.** It now clears
|
||||
`urgencyOverride` and re-syncs the stored quadrant per task.
|
||||
|
||||
Files: `NotificationManager.swift`, `TaskItem.swift`.
|
||||
|
||||
### Pending (not closed)
|
||||
- **#2 — Period-milestone Settings toggle.** Currently only the
|
||||
`kisani.periodMilestones` UserDefaults flag (default on); no in-app UI to turn
|
||||
the day/week/month/year notifications on/off. **Pending.**
|
||||
- **#6 — Configurable Matrix thresholds & snooze durations.** `urgentWindowDays`
|
||||
(3), `categoryUrgentWindowDays` (14), and the 15/30/60/120-min snooze actions
|
||||
are hardcoded constants — fine for now, but no settings for TickTick-style
|
||||
customization. **Pending.**
|
||||
- **Note (#1) — Matrix "Move" lists only the other 3 quadrants.** This is the
|
||||
intended KC-25 behavior, not a defect; left as-is unless reversed.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import WidgetKit
|
||||
|
||||
final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable {
|
||||
static let shared = NotificationManager()
|
||||
@@ -14,8 +15,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
private static let categoryId = "TASK_REMINDER"
|
||||
private static let actionMarkId = "MARK_COMPLETE"
|
||||
private static let taskSnoozePrefix = "TASK_SNOOZE_"
|
||||
private static let confirmCatId = "WORKOUT_CONFIRM"
|
||||
private static let logWorkoutActId = "LOG_WORKOUT"
|
||||
private static let missedWorkoutActId = "MISSED_WORKOUT"
|
||||
private static let snoozeWorkoutActId = "SNOOZE_WORKOUT"
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
@@ -26,12 +30,23 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
private func registerCategories() {
|
||||
let markAction = UNNotificationAction(identifier: Self.actionMarkId, title: "Mark Complete", options: [])
|
||||
let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction],
|
||||
let taskSnoozeActions = [
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "15", title: "Snooze 15 min", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "30", title: "Snooze 30 min", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "60", title: "Snooze 1 hour", options: []),
|
||||
UNNotificationAction(identifier: Self.taskSnoozePrefix + "120", title: "Snooze 2 hours", options: [])
|
||||
]
|
||||
let taskCat = UNNotificationCategory(identifier: Self.categoryId, actions: [markAction] + taskSnoozeActions,
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
let logAction = UNNotificationAction(identifier: Self.logWorkoutActId,
|
||||
title: "Yes, log it ✓", options: [])
|
||||
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId, actions: [logAction],
|
||||
title: "Yes, I completed it ✓", options: [])
|
||||
let missedAction = UNNotificationAction(identifier: Self.missedWorkoutActId,
|
||||
title: "No, I didn't", options: [])
|
||||
let snoozeAction = UNNotificationAction(identifier: Self.snoozeWorkoutActId,
|
||||
title: "Remind me in 1 hour", options: [])
|
||||
let confirmCat = UNNotificationCategory(identifier: Self.confirmCatId,
|
||||
actions: [logAction, missedAction, snoozeAction],
|
||||
intentIdentifiers: [], options: .customDismissAction)
|
||||
|
||||
center.setNotificationCategories([taskCat, confirmCat])
|
||||
@@ -49,9 +64,36 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
tasks[idx].completedAt = Date()
|
||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.kisani.set(encoded, forKey: key)
|
||||
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
private func snoozeTask(taskId: String, userId: String, minutes: Int, content: UNNotificationContent) {
|
||||
let key = "kisani.tasks.v2.\(userId)"
|
||||
let fireDate = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
if let data = UserDefaults.kisani.data(forKey: key),
|
||||
var tasks = try? JSONDecoder().decode([TaskItem].self, from: data),
|
||||
let uuid = UUID(uuidString: taskId),
|
||||
let idx = tasks.firstIndex(where: { $0.id == uuid }) {
|
||||
tasks[idx].reminderDate = fireDate
|
||||
if let encoded = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.kisani.set(encoded, forKey: key)
|
||||
CloudSyncManager.shared.push(data: encoded, forKey: key) // sync to iCloud / Watch
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
let mutable = content.mutableCopy() as! UNMutableNotificationContent
|
||||
mutable.categoryIdentifier = Self.categoryId
|
||||
mutable.userInfo = content.userInfo
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: "kisani.task.snooze.\(taskId).\(minutes).\(UUID().uuidString)",
|
||||
content: mutable,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: TimeInterval(minutes * 60), repeats: false)
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Permission
|
||||
|
||||
func requestPermission() {
|
||||
@@ -79,7 +121,11 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
let progress = workoutVM.progress
|
||||
let recorded = workoutVM.isTodayRecorded
|
||||
let tasks = taskVM.tasks
|
||||
// Live urgency (computed Matrix quadrant), not the stored fallback, so notification
|
||||
// copy reflects tasks that have become urgent as their dates approached.
|
||||
let urgentIds = Set(tasks.filter { taskVM.displayQuadrant($0) == .urgent }.map { $0.id })
|
||||
let calEvents = CalendarStore.shared.upcomingEventNotifs(days: 7)
|
||||
let compensations = workoutVM.compensations
|
||||
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
guard let self else { return }
|
||||
@@ -88,12 +134,124 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
guard ok else { return }
|
||||
self.scheduleWorkout(schedule: schedule, programs: programs, progress: progress, recordedToday: recorded)
|
||||
self.scheduleWorkoutConfirm(schedule: schedule, programs: programs)
|
||||
self.scheduleTasks(snapshot: tasks)
|
||||
self.scheduleCompensations(compensations, programs: programs)
|
||||
self.scheduleTasks(snapshot: tasks, urgentIds: urgentIds)
|
||||
self.scheduleCalendarEvents(snapshot: calEvents)
|
||||
self.schedulePeriodMilestones()
|
||||
self.updateBadge(snapshot: tasks)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Period milestones ("one more week / month / year passed")
|
||||
|
||||
private static let periodIds = ["kisani.period.day", "kisani.period.week", "kisani.period.month", "kisani.period.year"]
|
||||
|
||||
/// Recurring nudges at the close of each week, month, and year. Fixed identifiers
|
||||
/// + remove-then-add keep them de-duplicated across reschedules. Disabled (and
|
||||
/// cleared) when the user turns off the "Period milestones" setting.
|
||||
private func schedulePeriodMilestones() {
|
||||
center.removePendingNotificationRequests(withIdentifiers: Self.periodIds)
|
||||
guard UserDefaults.kisani.object(forKey: "kisani.periodMilestones") as? Bool ?? true else { return }
|
||||
|
||||
func add(_ id: String, _ title: String, _ body: String, _ comps: DateComponents) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: id, content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)))
|
||||
}
|
||||
|
||||
// Every midnight → a new day began
|
||||
var day = DateComponents(); day.hour = 0; day.minute = 0
|
||||
add(Self.periodIds[0], "Today", "One more day passed.", day)
|
||||
|
||||
// Start of the week (Mon 00:00) → the week that just ended
|
||||
var week = DateComponents(); week.weekday = 2; week.hour = 0; week.minute = 0
|
||||
add(Self.periodIds[1], "This Week", "One more week passed.", week)
|
||||
|
||||
// 1st of the month, 00:00 → the month that just ended
|
||||
var month = DateComponents(); month.day = 1; month.hour = 0; month.minute = 0
|
||||
add(Self.periodIds[2], "This Month", "One more month passed.", month)
|
||||
|
||||
// Jan 1, 00:00 → the year that just ended
|
||||
var year = DateComponents(); year.month = 1; year.day = 1; year.hour = 0; year.minute = 0
|
||||
add(Self.periodIds[3], "This Year", "One more year passed.", year)
|
||||
}
|
||||
|
||||
// MARK: - Recurring task completion confirmation
|
||||
|
||||
/// Posts a quiet "✓ Done. Repeats …" confirmation when a recurring task's
|
||||
/// occurrence is completed, telling the user when it next comes around.
|
||||
func notifyRecurringCompleted(title: String, nextOccurrence: Date?) {
|
||||
center.getNotificationSettings { [weak self] settings in
|
||||
guard let self,
|
||||
settings.authorizationStatus == .authorized
|
||||
|| settings.authorizationStatus == .provisional else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = nextOccurrence.map { "✓ Done. Repeats \(Self.relativeRepeat(to: $0))." } ?? "✓ Done."
|
||||
content.sound = nil // quiet confirmation, not an alert
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.recurdone.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)))
|
||||
}
|
||||
}
|
||||
|
||||
/// "tomorrow", "in 1 week", "in 3 days" … relative to today.
|
||||
static func relativeRepeat(to date: Date) -> String {
|
||||
let cal = Calendar.current
|
||||
let days = cal.dateComponents([.day], from: cal.startOfDay(for: Date()),
|
||||
to: cal.startOfDay(for: date)).day ?? 0
|
||||
switch days {
|
||||
case ..<1: return "again today"
|
||||
case 1: return "tomorrow"
|
||||
case 2...6: return "in \(days) days"
|
||||
case 7: return "in 1 week"
|
||||
default:
|
||||
if days % 7 == 0 { let w = days / 7; return "in \(w) week\(w == 1 ? "" : "s")" }
|
||||
if days % 30 == 0 { let m = days / 30; return "in \(m) month\(m == 1 ? "" : "s")" }
|
||||
return "in \(days) days"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compensation workout reminders (one-off, on chosen rest days)
|
||||
|
||||
private func scheduleCompensations(_ comps: [String: String], programs: [WorkoutProgram]) {
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
guard let self else { return }
|
||||
let old = pending.filter { $0.identifier.hasPrefix("kisani.workout.comp.") }.map { $0.identifier }
|
||||
self.center.removePendingNotificationRequests(withIdentifiers: old)
|
||||
|
||||
let ud = UserDefaults.kisani
|
||||
let rHour = ud.object(forKey: "workoutHour") as? Int ?? 18
|
||||
let rMinute = ud.object(forKey: "workoutMinute") as? Int ?? 0
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
let cal = Calendar.current
|
||||
|
||||
for (dateStr, pidStr) in comps {
|
||||
guard let day = fmt.date(from: dateStr), day > Date().addingTimeInterval(-86400),
|
||||
let pid = UUID(uuidString: pidStr),
|
||||
let program = programs.first(where: { $0.id == pid }) else { continue }
|
||||
var comps = cal.dateComponents([.year, .month, .day], from: day)
|
||||
comps.hour = rHour; comps.minute = rMinute
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "\(program.name) — Compensation"
|
||||
content.body = "Missed workout recovery — time to make it up!"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
self.center.add(UNNotificationRequest(
|
||||
identifier: "kisani.workout.comp.\(dateStr)",
|
||||
content: content,
|
||||
trigger: UNCalendarNotificationTrigger(dateMatching: comps, repeats: false)
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar event notifications
|
||||
|
||||
/// Notifies for visible calendar events: at start time ("on time") plus each
|
||||
@@ -191,6 +349,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
content.title = program.name
|
||||
content.body = "Time to work out — let's go!"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
var comps = DateComponents()
|
||||
comps.weekday = weekday; comps.hour = rHour; comps.minute = rMinute
|
||||
@@ -204,8 +363,9 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
if checkInOn {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = "\(program.name) — check in"
|
||||
content.body = doneToday ? "Great work today!" : "Did you finish? Mark your sets complete."
|
||||
content.body = doneToday ? "Great work today!" : "Have you done this workout?"
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.confirmCatId // Yes / No / Snooze
|
||||
content.userInfo = ["deeplink": "workout"]
|
||||
var comps = DateComponents()
|
||||
comps.weekday = weekday; comps.hour = cHour; comps.minute = cMinute
|
||||
@@ -272,7 +432,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
// MARK: - Per-task reminders + evening check-in
|
||||
|
||||
private func scheduleTasks(snapshot: [TaskItem]) {
|
||||
private func scheduleTasks(snapshot: [TaskItem], urgentIds: Set<UUID>) {
|
||||
let center = self.center
|
||||
|
||||
center.getPendingNotificationRequests { [weak self] pending in
|
||||
@@ -309,7 +469,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = task.title
|
||||
content.body = self.notifBody(for: task)
|
||||
content.body = self.notifBody(for: task, isUrgent: urgentIds.contains(task.id))
|
||||
content.sound = .default
|
||||
content.categoryIdentifier = Self.categoryId
|
||||
content.userInfo = ["taskId": task.id.uuidString, "deeplink": "tasks"]
|
||||
@@ -328,7 +488,7 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
|
||||
// No count in body — repeating notifications bake in the value
|
||||
// at schedule time and go stale until the app is next opened.
|
||||
guard snapshot.contains(where: { !$0.isComplete && $0.quadrant == .urgent }) else { return }
|
||||
guard snapshot.contains(where: { !$0.isComplete && urgentIds.contains($0.id) }) else { return }
|
||||
let ec = UNMutableNotificationContent()
|
||||
ec.title = "Evening check-in"
|
||||
ec.body = "You still have urgent tasks open — tap to review."
|
||||
@@ -343,12 +503,12 @@ final class NotificationManager: NSObject, ObservableObject, @unchecked Sendable
|
||||
}
|
||||
}
|
||||
|
||||
private func notifBody(for task: TaskItem) -> String {
|
||||
private func notifBody(for task: TaskItem, isUrgent: Bool) -> String {
|
||||
switch task.category {
|
||||
case .birthday: return "Birthday today"
|
||||
case .domain: return "Domain renewal coming up"
|
||||
case .annual: return "Annual reminder"
|
||||
default: return task.quadrant == .urgent ? "Urgent — tap to mark complete" : "Task reminder"
|
||||
default: return isUrgent ? "Urgent — tap to mark complete" : "Task reminder"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -379,11 +539,32 @@ extension NotificationManager: UNUserNotificationCenterDelegate {
|
||||
case Self.actionMarkId:
|
||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||
|
||||
case let action where action.hasPrefix(Self.taskSnoozePrefix):
|
||||
if let taskId,
|
||||
let minutes = Int(action.replacingOccurrences(of: Self.taskSnoozePrefix, with: "")) {
|
||||
snoozeTask(taskId: taskId, userId: userId, minutes: minutes,
|
||||
content: response.notification.request.content)
|
||||
}
|
||||
|
||||
case Self.logWorkoutActId:
|
||||
// "Yes, log it" from workout confirm notification — store date for WorkoutViewModel to pick up on next foreground
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingConfirm")
|
||||
|
||||
case Self.missedWorkoutActId:
|
||||
// "No, I didn't" — mark the day missed; the app asks about compensation on next open.
|
||||
let fmt = DateFormatter(); fmt.dateFormat = "yyyy-MM-dd"
|
||||
UserDefaults.kisani.set(fmt.string(from: Date()), forKey: "kisani.workout.pendingMissed")
|
||||
|
||||
case Self.snoozeWorkoutActId:
|
||||
// Re-deliver this same check-in in one hour.
|
||||
let content = response.notification.request.content.mutableCopy() as! UNMutableNotificationContent
|
||||
center.add(UNNotificationRequest(
|
||||
identifier: "kisani.workout.snooze.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 3600, repeats: false)
|
||||
))
|
||||
|
||||
default:
|
||||
if let taskId { markTaskComplete(taskId: taskId, userId: userId) }
|
||||
Task { @MainActor in
|
||||
|
||||
@@ -23,7 +23,7 @@ final class TutorialManager: ObservableObject {
|
||||
|
||||
let steps: [TutorialStep] = [
|
||||
.init(title: "Add Tasks with NLP",
|
||||
body: "Type naturally — \"dentist tomorrow 3pm\" or \"mum's birthday on 8th June every year\" — Kisani parses the date, time, and recurrence for you.",
|
||||
body: "Type naturally — \"dentist tomorrow 3pm\" or \"mum's birthday on 8th June every year\" — Wenza parses the date, time, and recurrence for you.",
|
||||
icon: "sparkles",
|
||||
anchorIndex: 0),
|
||||
.init(title: "Calendar",
|
||||
@@ -62,7 +62,7 @@ final class TutorialManager: ObservableObject {
|
||||
body: "Everything due today lives here — overdue items appear at the top so nothing slips through. Tap any task to open it.",
|
||||
icon: "checkmark.circle"),
|
||||
.init(title: "Add Tasks Instantly",
|
||||
body: "Tap + and type naturally. \"Doctor Wednesday 2pm\" or \"Neo's birthday 15 June yearly\" — Kisani handles dates, times, and recurrence.",
|
||||
body: "Tap + and type naturally. \"Doctor Wednesday 2pm\" or \"Neo's birthday 15 June yearly\" — Wenza handles dates, times, and recurrence.",
|
||||
icon: "sparkles"),
|
||||
.init(title: "Quick Actions",
|
||||
body: "Long-press a task to pin, postpone, or move it to a matrix quadrant. Swipe left to delete.",
|
||||
|
||||
@@ -67,6 +67,34 @@ struct WorkoutDayLog: Identifiable, Codable {
|
||||
|
||||
var totalSets: Int { exercises.reduce(0) { $0 + $1.sets.count } }
|
||||
var doneSets: Int { exercises.reduce(0) { $0 + $1.sets.filter(\.done).count } }
|
||||
/// Total training volume in kg for completed sets (Σ weight × reps).
|
||||
var volume: Double {
|
||||
exercises.reduce(0) { sum, ex in
|
||||
sum + ex.sets.reduce(0) { $0 + ($1.done ? $1.weight * Double($1.reps) : 0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout stats aggregation
|
||||
|
||||
enum StatPeriod: String, CaseIterable, Identifiable {
|
||||
case day = "Day", week = "Week", month = "Month"
|
||||
var id: String { rawValue }
|
||||
var calComponent: Calendar.Component {
|
||||
switch self { case .day: return .day; case .week: return .weekOfYear; case .month: return .month }
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkoutStat {
|
||||
var workouts: Int = 0
|
||||
var sets: Int = 0
|
||||
var volume: Double = 0
|
||||
}
|
||||
|
||||
struct StatBucket: Identifiable {
|
||||
let id = UUID()
|
||||
let label: String
|
||||
let stat: WorkoutStat
|
||||
}
|
||||
|
||||
struct LoggedExercise: Identifiable, Codable {
|
||||
@@ -205,7 +233,16 @@ class WorkoutViewModel: ObservableObject {
|
||||
@Published var activeProgramId: UUID
|
||||
@Published var activeSections: [WorkoutSection]
|
||||
@Published var workoutDates: [String] = []
|
||||
@Published var missedDates: [String] = [] // days marked "didn't do it"
|
||||
@Published var compensations: [String: String] = [:] // rest-day "yyyy-MM-dd" → programId
|
||||
@Published var askCompensate: MissedDay? = nil // triggers the compensation sheet
|
||||
@Published var history: [WorkoutDayLog] = []
|
||||
|
||||
struct MissedDay: Identifiable {
|
||||
let id: String // "yyyy-MM-dd" of the missed day
|
||||
let programId: UUID? // what was scheduled that day
|
||||
let programName: String
|
||||
}
|
||||
@Published var restTimerSeconds: Int = 60
|
||||
@Published var restTimerEnabled: Bool = false
|
||||
@Published var restCountdown: Int = 0
|
||||
@@ -239,6 +276,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
private let kWorkoutDates: String
|
||||
private let kLastActiveDay: String
|
||||
private let kWorkoutHistory: String
|
||||
private let kMissedDates: String
|
||||
private let kCompensations: String
|
||||
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
@@ -248,6 +287,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||
self.kLastActiveDay = "kisani.workout.lastActiveDay.\(uid)"
|
||||
self.kWorkoutHistory = "kisani.workout.history.\(uid)"
|
||||
self.kMissedDates = "kisani.workout.missedDates.\(uid)"
|
||||
self.kCompensations = "kisani.workout.compensations.\(uid)"
|
||||
|
||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
||||
@@ -317,6 +358,8 @@ class WorkoutViewModel: ObservableObject {
|
||||
activeSections = blank.sections
|
||||
}
|
||||
workoutDates = UserDefaults.kisani.stringArray(forKey: kWorkoutDates) ?? []
|
||||
missedDates = UserDefaults.kisani.stringArray(forKey: kMissedDates) ?? []
|
||||
compensations = (UserDefaults.kisani.dictionary(forKey: kCompensations) as? [String: String]) ?? [:]
|
||||
if let hData = UserDefaults.kisani.data(forKey: kWorkoutHistory),
|
||||
let saved = try? JSONDecoder().decode([WorkoutDayLog].self, from: hData) {
|
||||
history = saved
|
||||
@@ -333,6 +376,10 @@ class WorkoutViewModel: ObservableObject {
|
||||
UserDefaults.kisani.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.kisani.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.kisani.set(workoutDates, forKey: kWorkoutDates)
|
||||
UserDefaults.kisani.set(missedDates, forKey: kMissedDates)
|
||||
UserDefaults.kisani.set(compensations, forKey: kCompensations)
|
||||
CloudSyncManager.shared.push(value: missedDates, forKey: kMissedDates)
|
||||
CloudSyncManager.shared.push(value: compensations, forKey: kCompensations)
|
||||
if let hData = try? JSONEncoder().encode(history) {
|
||||
UserDefaults.kisani.set(hData, forKey: kWorkoutHistory)
|
||||
CloudSyncManager.shared.push(data: hData, forKey: kWorkoutHistory)
|
||||
@@ -382,13 +429,53 @@ class WorkoutViewModel: ObservableObject {
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Stats (daily / weekly / monthly performance)
|
||||
|
||||
/// History logs whose date falls inside `interval`.
|
||||
private func logs(in interval: DateInterval) -> [WorkoutDayLog] {
|
||||
history.filter { guard let d = iso.date(from: $0.date) else { return false }
|
||||
return interval.contains(d) }
|
||||
}
|
||||
|
||||
/// The calendar interval for `period`, shifted back by `offset` periods (0 = current).
|
||||
func statInterval(_ period: StatPeriod, offset: Int) -> DateInterval {
|
||||
let cal = Calendar.current
|
||||
let anchor = cal.date(byAdding: period.calComponent, value: offset, to: Date()) ?? Date()
|
||||
return cal.dateInterval(of: period.calComponent, for: anchor)
|
||||
?? DateInterval(start: cal.startOfDay(for: anchor), duration: 86400)
|
||||
}
|
||||
|
||||
/// Aggregate workout stats for one period (offset 0 = current, -1 = previous).
|
||||
func stat(_ period: StatPeriod, offset: Int = 0) -> WorkoutStat {
|
||||
let logs = logs(in: statInterval(period, offset: offset))
|
||||
return WorkoutStat(
|
||||
workouts: logs.count,
|
||||
sets: logs.reduce(0) { $0 + $1.doneSets },
|
||||
volume: logs.reduce(0) { $0 + $1.volume }
|
||||
)
|
||||
}
|
||||
|
||||
/// A series of `count` recent periods, oldest → newest, for charting.
|
||||
func statBuckets(_ period: StatPeriod, count: Int) -> [StatBucket] {
|
||||
let fmt = DateFormatter()
|
||||
switch period {
|
||||
case .day: fmt.dateFormat = "EEE"
|
||||
case .week: fmt.dateFormat = "MMM d"
|
||||
case .month: fmt.dateFormat = "MMM"
|
||||
}
|
||||
return (0..<count).reversed().map { back in
|
||||
let interval = statInterval(period, offset: -back)
|
||||
return StatBucket(label: fmt.string(from: interval.start), stat: stat(period, offset: -back))
|
||||
}
|
||||
}
|
||||
|
||||
/// The program assigned to today in the weekly schedule (nil = rest day).
|
||||
var todaysScheduledProgram: WorkoutProgram? { program(for: Date()) }
|
||||
|
||||
/// True when a schedule exists but today has no program — a rest day.
|
||||
/// A compensation scheduled for today counts as a workout day, not rest.
|
||||
var isRestDayToday: Bool {
|
||||
let weekday = Calendar.current.component(.weekday, from: Date())
|
||||
return !schedule.isEmpty && schedule[weekday] == nil
|
||||
!schedule.isEmpty && program(for: Date()) == nil
|
||||
}
|
||||
|
||||
/// On a new day, make the active program match the day's schedule so the
|
||||
@@ -492,6 +579,79 @@ class WorkoutViewModel: ObservableObject {
|
||||
workoutDates.contains(iso.string(from: date))
|
||||
}
|
||||
|
||||
// MARK: - Per-date status (Pending / Done / Missed / Compensation)
|
||||
|
||||
enum WorkoutDayStatus { case none, pending, done, missed }
|
||||
|
||||
func isWorkoutMissed(on date: Date) -> Bool { missedDates.contains(iso.string(from: date)) }
|
||||
func isCompensation(on date: Date) -> Bool { compensations[iso.string(from: date)] != nil }
|
||||
|
||||
/// Status of the workout scheduled on a specific date. Done wins over missed.
|
||||
func workoutStatus(on date: Date) -> WorkoutDayStatus {
|
||||
guard program(for: date) != nil else { return .none }
|
||||
if isWorkoutComplete(on: date) { return .done }
|
||||
if isWorkoutMissed(on: date) { return .missed }
|
||||
return .pending
|
||||
}
|
||||
|
||||
/// Mark one specific day's workout done — never touches other occurrences.
|
||||
func markWorkoutDone(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
missedDates.removeAll { $0 == key }
|
||||
logWorkoutCompleted(on: date) // adds to workoutDates, snapshots history, saves
|
||||
}
|
||||
|
||||
/// Mark one specific day's workout missed (undoes a done mark for that day only).
|
||||
func markWorkoutMissed(on date: Date) {
|
||||
let key = iso.string(from: date)
|
||||
workoutDates.removeAll { $0 == key }
|
||||
if !missedDates.contains(key) { missedDates.append(key) }
|
||||
save()
|
||||
}
|
||||
|
||||
// MARK: - Compensation (recover a missed workout on a rest day)
|
||||
|
||||
/// Upcoming rest days (no scheduled program, no compensation yet), starting tomorrow.
|
||||
func upcomingRestDays(within days: Int = 14) -> [Date] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
return (1...days).compactMap { off -> Date? in
|
||||
guard let d = cal.date(byAdding: .day, value: off, to: today) else { return nil }
|
||||
let weekday = cal.component(.weekday, from: d)
|
||||
guard schedule[weekday] == nil, compensations[iso.string(from: d)] == nil else { return nil }
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func scheduleCompensation(programId: UUID, on date: Date) {
|
||||
compensations[iso.string(from: date)] = programId.uuidString
|
||||
save()
|
||||
}
|
||||
|
||||
func removeCompensation(on date: Date) {
|
||||
compensations.removeValue(forKey: iso.string(from: date))
|
||||
save()
|
||||
}
|
||||
|
||||
/// Begin the "compensate for this missed day?" flow for a given date.
|
||||
func promptCompensation(forMissed date: Date) {
|
||||
let prog = program(for: date)
|
||||
askCompensate = MissedDay(id: iso.string(from: date),
|
||||
programId: prog?.id,
|
||||
programName: prog?.name ?? "Workout")
|
||||
}
|
||||
|
||||
/// Picks up "No, I missed it" tapped on a workout notification (set in background).
|
||||
@MainActor
|
||||
func checkPendingMissed() {
|
||||
let key = "kisani.workout.pendingMissed"
|
||||
guard let dateStr = UserDefaults.kisani.string(forKey: key) else { return }
|
||||
UserDefaults.kisani.removeObject(forKey: key)
|
||||
guard let date = iso.date(from: dateStr) else { return }
|
||||
markWorkoutMissed(on: date)
|
||||
promptCompensation(forMissed: date)
|
||||
}
|
||||
|
||||
// MARK: - Program management
|
||||
func switchProgram(to id: UUID) {
|
||||
syncBack()
|
||||
@@ -529,6 +689,12 @@ class WorkoutViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func program(for date: Date) -> WorkoutProgram? {
|
||||
// A compensation scheduled on this (rest) day takes the slot.
|
||||
if let pidStr = compensations[iso.string(from: date)],
|
||||
let pid = UUID(uuidString: pidStr),
|
||||
let prog = programs.first(where: { $0.id == pid }) {
|
||||
return prog
|
||||
}
|
||||
let weekday = Calendar.current.component(.weekday, from: date)
|
||||
guard let pid = schedule[weekday] else { return nil }
|
||||
return programs.first { $0.id == pid }
|
||||
@@ -587,6 +753,37 @@ class WorkoutViewModel: ObservableObject {
|
||||
if wasMarkedDone && restTimerEnabled { startRestTimer() }
|
||||
}
|
||||
|
||||
/// Mark every set of one exercise done/undone (long-press quick action).
|
||||
func setExerciseDone(sectionId: UUID, exerciseId: UUID, done: Bool) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
else { return }
|
||||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||||
}
|
||||
if done, sessionStartDate == nil { sessionStartDate = Date() }
|
||||
syncBack()
|
||||
if doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||
}
|
||||
|
||||
/// Mark every set of the active workout done/undone — the "Select all & mark
|
||||
/// complete" quick action. Logs completion when everything is checked.
|
||||
func setAllSetsDone(_ done: Bool) {
|
||||
for si in activeSections.indices {
|
||||
for ei in activeSections[si].exercises.indices {
|
||||
for idx in activeSections[si].exercises[ei].sets.indices {
|
||||
activeSections[si].exercises[ei].sets[idx].isDone = done
|
||||
}
|
||||
}
|
||||
}
|
||||
if done {
|
||||
if sessionStartDate == nil { sessionStartDate = Date() }
|
||||
UserDefaults.kisani.set(iso.string(from: Date()), forKey: kLastActiveDay)
|
||||
}
|
||||
syncBack()
|
||||
if done, doneSets == totalSets && totalSets > 0 { logWorkoutCompleted() }
|
||||
}
|
||||
|
||||
func addSet(sectionId: UUID, to exerciseId: UUID) {
|
||||
guard let si = activeSections.firstIndex(where: { $0.id == sectionId }),
|
||||
let ei = activeSections[si].exercises.firstIndex(where: { $0.id == exerciseId })
|
||||
|
||||
@@ -20,6 +20,12 @@ struct TaskItem: Identifiable, Codable, Equatable {
|
||||
var priority: Priority = .none
|
||||
var reminderDate: Date? = nil
|
||||
var constantReminder: Bool = false
|
||||
// Recurrence: optional so existing saved tasks decode safely (missing key → nil).
|
||||
var recurrenceEnd: Date? = nil // "repeat until" (nil = open-ended)
|
||||
var completedOccurrences: [String]? = nil // "yyyy-MM-dd" of completed occurrences
|
||||
// Matrix: horizontal manual moves override urgency only.
|
||||
// nil = auto from deadline, true = force urgent, false = force not-urgent.
|
||||
var urgencyOverride: Bool? = nil
|
||||
}
|
||||
|
||||
enum Priority: String, Codable, CaseIterable, Identifiable {
|
||||
@@ -146,6 +152,22 @@ class TaskViewModel: ObservableObject {
|
||||
} else {
|
||||
tasks = []
|
||||
}
|
||||
migrateCategoryPrioritiesIfNeeded()
|
||||
}
|
||||
|
||||
/// One-time backfill: tasks created before Priority existed all have `.none`.
|
||||
/// Apply the KisaniCal category defaults (birthdays → High, etc.) so the Matrix
|
||||
/// places legacy birthdays/renewals in the top row like newly-created ones.
|
||||
private func migrateCategoryPrioritiesIfNeeded() {
|
||||
let flag = "kisani.migration.categoryPriority.v1"
|
||||
guard !UserDefaults.kisani.bool(forKey: flag) else { return }
|
||||
var changed = false
|
||||
for idx in tasks.indices where tasks[idx].priority == .none {
|
||||
let def = defaultPriority(category: tasks[idx].category, title: tasks[idx].title)
|
||||
if def != .none { tasks[idx].priority = def; changed = true }
|
||||
}
|
||||
UserDefaults.kisani.set(true, forKey: flag)
|
||||
if changed { save() }
|
||||
}
|
||||
|
||||
func reload() {
|
||||
@@ -163,43 +185,76 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var overdueTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
}
|
||||
var todayTasks: [TaskItem] {
|
||||
let start = Calendar.current.startOfDay(for: Date())
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start)!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= start && ($0.dueDate ?? .distantFuture) < end }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var upcomingTasks: [TaskItem] {
|
||||
// MARK: - Active list rows (single occurrence per task — TickTick model)
|
||||
//
|
||||
// A recurring task contributes exactly ONE row: its next active (incomplete)
|
||||
// occurrence. Completing it advances to the next occurrence — there are never
|
||||
// multiple future copies of the same task across sections. (The calendar grid
|
||||
// still shows every occurrence per-date; that's a date grid, not a list.)
|
||||
|
||||
/// Earliest occurrence on/after `from` not yet completed — the task's one active
|
||||
/// occurrence. Daily → today (or tomorrow once today's done); Yearly → the next
|
||||
/// uncompleted yearly date; etc.
|
||||
func nextActiveOccurrence(_ t: TaskItem, from: Date = Date()) -> Date? {
|
||||
guard recurs(t), let start = t.dueDate else { return nil }
|
||||
let cal = Calendar.current
|
||||
let fourDays = cal.date(byAdding: .day, value: 4, to: cal.startOfDay(for: Date()))!
|
||||
return tasks
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= fourDays }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var next3DaysTasks: [(date: Date, tasks: [TaskItem])] {
|
||||
let cal = Calendar.current
|
||||
let today = cal.startOfDay(for: Date())
|
||||
return (1...3).compactMap { offset -> (Date, [TaskItem])? in
|
||||
guard let day = cal.date(byAdding: .day, value: offset, to: today),
|
||||
let nextDay = cal.date(byAdding: .day, value: 1, to: day) else { return nil }
|
||||
let dayTasks = tasks.filter {
|
||||
!$0.isComplete &&
|
||||
($0.dueDate ?? .distantFuture) >= day &&
|
||||
($0.dueDate ?? .distantFuture) < nextDay
|
||||
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
return dayTasks.isEmpty ? nil : (day, dayTasks)
|
||||
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: from))
|
||||
for _ in 0..<4000 {
|
||||
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||
if isOccurrence(t, on: cur) && !occurrenceComplete(t, on: cur) { return cur }
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// One representative active row per task: non-recurring incomplete tasks, plus a
|
||||
/// single next-active occurrence copy for each recurring task.
|
||||
private func activeRows() -> [TaskItem] {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
if let occ = nextActiveOccurrence(t, from: today) { out.append(occurrenceCopy(t, on: occ)) }
|
||||
} else if !t.isComplete {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
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).
|
||||
private func bucket(from lo: Int, to hi: Int?) -> [TaskItem] {
|
||||
let cal = Calendar.current; let base = cal.startOfDay(for: Date())
|
||||
let start = cal.date(byAdding: .day, value: lo, to: base)!
|
||||
let end = hi.flatMap { cal.date(byAdding: .day, value: $0, to: base) }
|
||||
return activeRows().filter {
|
||||
let d = $0.dueDate ?? .distantFuture
|
||||
return d >= start && (end == nil || d < end!)
|
||||
}.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
|
||||
var overdueTasks: [TaskItem] {
|
||||
let today = Calendar.current.startOfDay(for: Date())
|
||||
// Non-recurring past-due only — recurring surfaces its next occurrence instead.
|
||||
return tasks.filter { !recurs($0) && !$0.isComplete && ($0.dueDate ?? .distantFuture) < today }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
var todayTasks: [TaskItem] { bucket(from: 0, to: 1) } // due today
|
||||
var tomorrowTasks: [TaskItem] { bucket(from: 1, to: 2) } // due tomorrow
|
||||
var next7DaysTasks: [TaskItem] { bucket(from: 2, to: 8) } // due in 2–7 days
|
||||
var laterTasks: [TaskItem] { bucket(from: 8, to: nil) } // beyond 7 days
|
||||
|
||||
var completedTodayTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return tasks
|
||||
.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
let cal = Calendar.current
|
||||
let startOfDay = cal.startOfDay(for: Date())
|
||||
let nonRec = tasks.filter { !recurs($0) && $0.isComplete && ($0.completedAt ?? .distantPast) >= startOfDay }
|
||||
let rec = tasks.filter { recurs($0) && isOccurrence($0, on: startOfDay) && occurrenceComplete($0, on: startOfDay) }
|
||||
.map { occurrenceCopy($0, on: startOfDay) }
|
||||
return (nonRec + rec).sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
/// All completed tasks, most-recent first (for the Completed archive section).
|
||||
var completedTasks: [TaskItem] {
|
||||
@@ -209,6 +264,11 @@ class TaskViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
func toggle(_ task: TaskItem) {
|
||||
// A recurring row carries its occurrence date in dueDate — toggle just that day.
|
||||
if recurs(task), let d = task.dueDate {
|
||||
toggleOccurrence(task, on: d)
|
||||
return
|
||||
}
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isComplete.toggle()
|
||||
tasks[idx].completedAt = tasks[idx].isComplete ? Date() : nil
|
||||
@@ -220,6 +280,185 @@ class TaskViewModel: ObservableObject {
|
||||
.sorted { $0.isPinned && !$1.isPinned }
|
||||
}
|
||||
|
||||
// MARK: - Recurrence engine
|
||||
|
||||
private static let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
func occurrenceKey(_ date: Date) -> String { Self.occFmt.string(from: date) }
|
||||
|
||||
/// Whether a task repeats on a schedule.
|
||||
func recurs(_ t: TaskItem) -> Bool {
|
||||
t.isRecurring && (t.recurrenceLabel ?? "None") != "None" && t.dueDate != nil
|
||||
}
|
||||
|
||||
/// Whether a recurring task has an occurrence on `date` — aligned to its rule,
|
||||
/// on/after its start, and on/before its "repeat until" (if set).
|
||||
func isOccurrence(_ t: TaskItem, on date: Date) -> Bool {
|
||||
guard recurs(t), let start = t.dueDate else { return false }
|
||||
let cal = Calendar.current
|
||||
let d0 = cal.startOfDay(for: date), s0 = cal.startOfDay(for: start)
|
||||
guard d0 >= s0 else { return false }
|
||||
if let end = t.recurrenceEnd, d0 > cal.startOfDay(for: end) { return false }
|
||||
switch t.recurrenceLabel {
|
||||
case "Daily": return true
|
||||
case "Every Weekday":
|
||||
let wd = cal.component(.weekday, from: d0) // 1=Sun … 7=Sat
|
||||
return wd >= 2 && wd <= 6
|
||||
case "Weekly": return ((cal.dateComponents([.day], from: s0, to: d0).day ?? 0) % 7) == 0
|
||||
case "Monthly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||
case "Yearly": return cal.component(.day, from: s0) == cal.component(.day, from: d0)
|
||||
&& cal.component(.month, from: s0) == cal.component(.month, from: d0)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func occurrenceComplete(_ t: TaskItem, on date: Date) -> Bool {
|
||||
t.completedOccurrences?.contains(occurrenceKey(date)) ?? false
|
||||
}
|
||||
|
||||
/// Toggle one occurrence's completion — never affects other dates.
|
||||
func toggleOccurrence(_ t: TaskItem, on date: Date) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == t.id }) else { return }
|
||||
let key = occurrenceKey(date)
|
||||
var set = Set(tasks[idx].completedOccurrences ?? [])
|
||||
let nowDone: Bool
|
||||
if set.contains(key) { set.remove(key); nowDone = false } else { set.insert(key); nowDone = true }
|
||||
tasks[idx].completedOccurrences = Array(set)
|
||||
save()
|
||||
// Completing a recurring occurrence → "✓ Done. Repeats …" confirmation.
|
||||
if nowDone {
|
||||
let next = nextActiveOccurrence(tasks[idx], from: date)
|
||||
NotificationManager.shared.notifyRecurringCompleted(title: tasks[idx].title, nextOccurrence: next)
|
||||
}
|
||||
}
|
||||
|
||||
/// A display copy of a recurring task pinned to one occurrence date, with that
|
||||
/// occurrence's completion baked into `isComplete` (so existing rows just work).
|
||||
func occurrenceCopy(_ t: TaskItem, on date: Date) -> TaskItem {
|
||||
var c = t
|
||||
let cal = Calendar.current
|
||||
if let due = t.dueDate {
|
||||
let hm = cal.dateComponents([.hour, .minute], from: due)
|
||||
c.dueDate = cal.date(bySettingHour: hm.hour ?? 0, minute: hm.minute ?? 0, second: 0, of: date) ?? date
|
||||
} else { c.dueDate = date }
|
||||
let done = occurrenceComplete(t, on: date)
|
||||
c.isComplete = done
|
||||
c.completedAt = done ? c.dueDate : nil
|
||||
return c
|
||||
}
|
||||
|
||||
/// Next occurrence on/after `date`, within the repeat-until.
|
||||
func nextOccurrence(_ t: TaskItem, onOrAfter date: Date) -> Date? {
|
||||
guard recurs(t), let start = t.dueDate else { return nil }
|
||||
let cal = Calendar.current
|
||||
var cur = max(cal.startOfDay(for: start), cal.startOfDay(for: date))
|
||||
for _ in 0..<4000 {
|
||||
if let end = t.recurrenceEnd, cur > cal.startOfDay(for: end) { return nil }
|
||||
if isOccurrence(t, on: cur) { return cur }
|
||||
cur = cal.date(byAdding: .day, value: 1, to: cur) ?? cur
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Eisenhower Matrix (TickTick-style: Priority × deadline, with urgency override)
|
||||
//
|
||||
// The Matrix is a VIEW of tasks, not a separate system. A task is auto-placed:
|
||||
// • Importance (top row) = Priority is High; Medium/Low/None = bottom row.
|
||||
// • Urgency (left column) = its (next active) due date is today/overdue/within
|
||||
// `urgentWindowDays`; undated or far-out = right column.
|
||||
// So Q1 = High + urgent, Q2 = High + not urgent, Q3 = not-High + urgent, Q4 = the rest.
|
||||
// Vertical drag changes Priority directly. Horizontal drag sets `urgencyOverride`
|
||||
// because we should not fabricate/destroy a real deadline just to move columns.
|
||||
// Editing the due date clears urgency override so the task re-places live.
|
||||
// Recurring tasks roll forward (a completed occurrence is replaced by the next).
|
||||
|
||||
static let urgentWindowDays = 3
|
||||
static let categoryUrgentWindowDays = 14
|
||||
|
||||
private func isImportant(_ t: TaskItem) -> Bool { t.priority == .high }
|
||||
|
||||
/// The due date that drives urgency now: a recurring task's next active
|
||||
/// occurrence, or a one-off task's own due date.
|
||||
private func effectiveDue(_ t: TaskItem, on today: Date) -> Date? {
|
||||
recurs(t) ? nextActiveOccurrence(t, from: today) : t.dueDate
|
||||
}
|
||||
|
||||
private func isUrgent(_ t: TaskItem, on today: Date) -> Bool {
|
||||
t.urgencyOverride ?? urgentByDeadline(t, on: today)
|
||||
}
|
||||
|
||||
/// Date-derived urgency, ignoring any manual override (overdue/today/within window).
|
||||
private func urgentByDeadline(_ t: TaskItem, on today: Date = Date()) -> Bool {
|
||||
guard let due = effectiveDue(t, on: today) else { return false } // undated → never urgent
|
||||
let days = Calendar.current.dateComponents([.day],
|
||||
from: Calendar.current.startOfDay(for: today),
|
||||
to: Calendar.current.startOfDay(for: due)).day ?? 0
|
||||
return days <= urgentWindow(for: t) // due soon / overdue
|
||||
}
|
||||
|
||||
private func urgentWindow(for t: TaskItem) -> Int {
|
||||
switch t.category {
|
||||
case .birthday, .domain, .annual:
|
||||
return Self.categoryUrgentWindowDays
|
||||
case .reminder, .custom:
|
||||
let title = t.title.lowercased()
|
||||
if title.contains("domain") || title.contains("subscription")
|
||||
|| title.contains("annual") || title.contains("renewal")
|
||||
|| title.contains("renew") || title.contains("exam")
|
||||
|| title.contains("deadline") {
|
||||
return Self.categoryUrgentWindowDays
|
||||
}
|
||||
return Self.urgentWindowDays
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto-placement from Priority (importance) × deadline (urgency).
|
||||
private func autoQuadrant(_ t: TaskItem, on today: Date) -> Quadrant {
|
||||
switch (isImportant(t), isUrgent(t, on: today)) {
|
||||
case (true, true): return .urgent
|
||||
case (true, false): return .schedule
|
||||
case (false, true): return .delegate_
|
||||
case (false, false): return .eliminate
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a task appears in the Matrix now.
|
||||
func displayQuadrant(_ t: TaskItem, on today: Date = Date()) -> Quadrant {
|
||||
autoQuadrant(t, on: today)
|
||||
}
|
||||
|
||||
/// Matrix rows for a quadrant, grouped by the *displayed* (computed) quadrant.
|
||||
/// Recurring tasks contribute their single next-active occurrence.
|
||||
func matrixTasks(for quadrant: Quadrant, on today: Date = Date()) -> [TaskItem] {
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
guard let occ = nextActiveOccurrence(t, from: today) else { continue }
|
||||
let copy = occurrenceCopy(t, on: occ)
|
||||
if displayQuadrant(copy, on: today) == quadrant { out.append(copy) }
|
||||
} else if displayQuadrant(t, on: today) == quadrant {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out.sorted { $0.isPinned && !$1.isPinned }
|
||||
}
|
||||
|
||||
/// All task instances on a given calendar day: non-recurring tasks due that day
|
||||
/// plus a copy of each recurring task that occurs that day. Used by the calendar.
|
||||
func occurrences(on date: Date) -> [TaskItem] {
|
||||
let cal = Calendar.current
|
||||
var out: [TaskItem] = []
|
||||
for t in tasks {
|
||||
if recurs(t) {
|
||||
if isOccurrence(t, on: date) { out.append(occurrenceCopy(t, on: date)) }
|
||||
} else if let d = t.dueDate, cal.isDate(d, inSameDayAs: date) {
|
||||
out.append(t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func pin(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].isPinned.toggle()
|
||||
@@ -229,47 +468,90 @@ class TaskViewModel: ObservableObject {
|
||||
func setDate(_ task: TaskItem, date: Date?) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].dueDate = date
|
||||
tasks[idx].urgencyOverride = nil // deadline changed → re-place from urgency
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setCategory(_ task: TaskItem, category: TaskCategory) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].category = category
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func setPriority(_ task: TaskItem, priority: Priority) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].priority = priority
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // color follows placement
|
||||
save()
|
||||
}
|
||||
|
||||
/// Manual drag/move in the Matrix. Vertical movement updates Priority directly.
|
||||
/// Horizontal movement sets an urgency override instead of mutating due dates —
|
||||
/// but only when the target column disagrees with the date-derived urgency, so a
|
||||
/// drag into a task's natural column leaves it on Auto (no false "Manual" flag).
|
||||
func moveToQuadrant(taskID: UUID, quadrant: Quadrant) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == taskID }) else { return }
|
||||
tasks[idx].quadrant = quadrant
|
||||
let importantRow = (quadrant == .urgent || quadrant == .schedule)
|
||||
let urgentColumn = (quadrant == .urgent || quadrant == .delegate_)
|
||||
if importantRow {
|
||||
tasks[idx].priority = .high
|
||||
} else if tasks[idx].priority == .high {
|
||||
tasks[idx].priority = .medium
|
||||
}
|
||||
tasks[idx].urgencyOverride = urgentByDeadline(tasks[idx]) == urgentColumn ? nil : urgentColumn
|
||||
tasks[idx].quadrant = quadrant // keep stored quadrant in sync (color + widget accent)
|
||||
save()
|
||||
}
|
||||
|
||||
func resetMatrixUrgencyOverride(_ task: TaskItem) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
/// KisaniCal priority defaults — high-stakes events start High, workouts Medium.
|
||||
/// Applied only when the user didn't pick a priority (== .none) at creation.
|
||||
private func defaultPriority(category: TaskCategory, title: String) -> Priority {
|
||||
switch category {
|
||||
case .birthday, .domain, .annual: return .high
|
||||
default: break
|
||||
}
|
||||
let t = title.lowercased()
|
||||
if t.contains("birthday") || t.contains("exam") || t.contains("subscription")
|
||||
|| t.contains("renew") || t.contains("domain") || t.contains("expir") { return .high }
|
||||
if t.contains("workout") || t.contains("gym") || t.contains("exercise") { return .medium }
|
||||
return .none
|
||||
}
|
||||
|
||||
func addTask(title: String, dueDate: Date?, hasTime: Bool = false,
|
||||
quadrant: Quadrant = .urgent, category: TaskCategory = .reminder,
|
||||
taskColor: TaskColor = .orange, isRecurring: Bool = false,
|
||||
recurrenceLabel: String? = nil,
|
||||
endDate: Date? = nil, isAllDay: Bool = false,
|
||||
priority: Priority = .none, reminderDate: Date? = nil,
|
||||
constantReminder: Bool = false) {
|
||||
tasks.append(TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||
quadrant: quadrant, category: category,
|
||||
taskColor: taskColor, isRecurring: isRecurring,
|
||||
recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay,
|
||||
priority: priority, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder))
|
||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
||||
let resolvedPriority = priority == .none
|
||||
? defaultPriority(category: category, title: title) : priority
|
||||
var item = TaskItem(title: title, dueDate: dueDate, hasTime: hasTime,
|
||||
quadrant: quadrant, category: category,
|
||||
taskColor: taskColor, isRecurring: isRecurring,
|
||||
recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay,
|
||||
priority: resolvedPriority, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder)
|
||||
item.recurrenceEnd = recurrenceEnd
|
||||
item.quadrant = displayQuadrant(item) // color/widget identity follows placement
|
||||
tasks.append(item)
|
||||
save()
|
||||
}
|
||||
|
||||
func updateTask(_ task: TaskItem, title: String, dueDate: Date?, hasTime: Bool,
|
||||
isRecurring: Bool, recurrenceLabel: String?,
|
||||
endDate: Date?, isAllDay: Bool, reminderDate: Date?,
|
||||
constantReminder: Bool = false) {
|
||||
constantReminder: Bool = false, recurrenceEnd: Date? = nil) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
tasks[idx].title = title
|
||||
tasks[idx].dueDate = dueDate
|
||||
@@ -280,6 +562,9 @@ class TaskViewModel: ObservableObject {
|
||||
tasks[idx].isAllDay = isAllDay
|
||||
tasks[idx].reminderDate = reminderDate
|
||||
tasks[idx].constantReminder = constantReminder
|
||||
tasks[idx].recurrenceEnd = recurrenceEnd
|
||||
tasks[idx].urgencyOverride = nil // editor saved a new deadline → re-place
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -287,6 +572,24 @@ class TaskViewModel: ObservableObject {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
let base = tasks[idx].dueDate ?? Date()
|
||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
save()
|
||||
}
|
||||
|
||||
func postpone(_ task: TaskItem, minutes: Int) {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return }
|
||||
let target = Date().addingTimeInterval(TimeInterval(minutes * 60))
|
||||
if tasks[idx].reminderDate != nil {
|
||||
tasks[idx].reminderDate = target
|
||||
} else if tasks[idx].dueDate != nil {
|
||||
tasks[idx].dueDate = target
|
||||
tasks[idx].hasTime = true
|
||||
tasks[idx].urgencyOverride = nil
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx])
|
||||
} else {
|
||||
tasks[idx].reminderDate = target
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
@@ -296,6 +599,8 @@ class TaskViewModel: ObservableObject {
|
||||
guard let idx = tasks.firstIndex(where: { $0.id == id }) else { continue }
|
||||
let base = tasks[idx].dueDate ?? Date()
|
||||
tasks[idx].dueDate = Calendar.current.date(byAdding: .day, value: days, to: base)
|
||||
tasks[idx].urgencyOverride = nil // re-place from the new deadline
|
||||
tasks[idx].quadrant = displayQuadrant(tasks[idx]) // keep color/fallback in sync
|
||||
}
|
||||
save()
|
||||
}
|
||||
@@ -305,4 +610,3 @@ class TaskViewModel: ObservableObject {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ struct AuthView: View {
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Kisani Cal")
|
||||
Text("Wenza")
|
||||
.font(AppFonts.sans(32, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("Your calendar, your way.")
|
||||
|
||||
@@ -298,6 +298,7 @@ struct CalendarView: View {
|
||||
@State private var showCalendarConnect = false
|
||||
@State private var showWorkoutSession = false
|
||||
@State private var editingEvent: EditableEvent? = nil
|
||||
@State private var editingTask: TaskItem? = nil
|
||||
|
||||
private let cal = Calendar.current
|
||||
private let weekdays = ["S","M","T","W","T","F","S"]
|
||||
@@ -326,9 +327,9 @@ struct CalendarView: View {
|
||||
}
|
||||
.menuStyle(.automatic)
|
||||
|
||||
// Center: month name
|
||||
// Center: month + year
|
||||
Spacer()
|
||||
Text(monthShortTitle)
|
||||
Text(monthTitle)
|
||||
.font(AppFonts.sans(17, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
@@ -411,6 +412,12 @@ struct CalendarView: View {
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.sheet(item: $editingTask) { task in
|
||||
TaskEditSheet(task: task)
|
||||
.environmentObject(taskVM)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.onAppear {
|
||||
calStore.refreshStatus()
|
||||
if calStore.isAuthorized { calStore.fetchMonth(containing: displayMonth) }
|
||||
@@ -463,9 +470,9 @@ struct CalendarView: View {
|
||||
dots.append(calColor)
|
||||
}
|
||||
}
|
||||
let taskDots = taskVM.tasks
|
||||
// Includes recurring occurrences that land on this date.
|
||||
let taskDots = taskVM.occurrences(on: date)
|
||||
.filter { !$0.isComplete }
|
||||
.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
||||
.prefix(max(0, 3 - dots.count))
|
||||
.map { $0.quadrant.color }
|
||||
dots.append(contentsOf: taskDots)
|
||||
@@ -475,10 +482,7 @@ struct CalendarView: View {
|
||||
// MARK: - Task list mode (toggle view)
|
||||
|
||||
private var calDayTaskListView: some View {
|
||||
let all = taskVM.tasks.filter { t in
|
||||
guard let d = t.dueDate else { return false }
|
||||
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||
}
|
||||
let all = taskVM.occurrences(on: selectedDate)
|
||||
let incomplete = all.filter { !$0.isComplete }
|
||||
let complete = all.filter { $0.isComplete }
|
||||
|
||||
@@ -581,13 +585,18 @@ struct CalendarView: View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
DayTimelineView(
|
||||
date: selectedDate,
|
||||
tasks: taskVM.tasks.filter { t in
|
||||
guard let d = t.dueDate else { return false }
|
||||
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||
},
|
||||
tasks: taskVM.occurrences(on: selectedDate),
|
||||
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
||||
workout: workoutVM.program(for: selectedDate),
|
||||
workoutDone: workoutVM.isWorkoutComplete(on: selectedDate),
|
||||
workoutMissed: workoutVM.isWorkoutMissed(on: selectedDate),
|
||||
workoutCompensation: workoutVM.isCompensation(on: selectedDate),
|
||||
onWorkoutDone: { workoutVM.markWorkoutDone(on: selectedDate) },
|
||||
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: selectedDate) },
|
||||
onWorkoutCompensate: {
|
||||
workoutVM.markWorkoutMissed(on: selectedDate)
|
||||
workoutVM.promptCompensation(forMissed: selectedDate)
|
||||
},
|
||||
onWorkoutTap: {
|
||||
if let w = workoutVM.program(for: selectedDate),
|
||||
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
|
||||
@@ -597,11 +606,14 @@ struct CalendarView: View {
|
||||
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
|
||||
},
|
||||
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) },
|
||||
onEditEvent: { editingEvent = EditableEvent(event: $0) },
|
||||
onDeleteEvent: { calStore.deleteEvent($0) }
|
||||
)
|
||||
@@ -618,10 +630,7 @@ struct CalendarView: View {
|
||||
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
let dayTasks = taskVM.tasks.filter { t in
|
||||
guard !t.isComplete, let d = t.dueDate else { return false }
|
||||
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||
}
|
||||
let dayTasks = taskVM.occurrences(on: selectedDate).filter { !$0.isComplete }
|
||||
let events = calStore.isAuthorized ? calStore.events(for: selectedDate) : []
|
||||
if dayTasks.isEmpty && events.isEmpty {
|
||||
cvEmptyDay
|
||||
@@ -673,13 +682,38 @@ struct CalendarView: View {
|
||||
private var yearView: some View {
|
||||
let year = cal.component(.year, from: displayMonth)
|
||||
let months: [Date] = (1...12).compactMap { cal.date(from: DateComponents(year: year, month: $0, day: 1)) }
|
||||
return ScrollView(showsIndicators: false) {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
|
||||
ForEach(months, id: \.self) { m in
|
||||
cvMiniMonth(m)
|
||||
return VStack(spacing: 0) {
|
||||
// Year header with navigation (unbounded — go as far forward/back as you like).
|
||||
HStack {
|
||||
Button { navigateYear(-1) } label: {
|
||||
Image(systemName: "chevron.left").font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
|
||||
}
|
||||
Spacer()
|
||||
Text(String(year))
|
||||
.font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button { navigateYear(1) } label: {
|
||||
Image(systemName: "chevron.right").font(.system(size: 14, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs)).frame(width: 40, height: 40)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(.horizontal, 16).padding(.top, 4)
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 24) {
|
||||
ForEach(months, id: \.self) { m in
|
||||
cvMiniMonth(m)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func navigateYear(_ offset: Int) {
|
||||
withAnimation(KisaniSpring.snappy) {
|
||||
displayMonth = cal.date(byAdding: .year, value: offset, to: displayMonth) ?? displayMonth
|
||||
}
|
||||
}
|
||||
|
||||
@@ -765,7 +799,7 @@ struct CalendarView: View {
|
||||
ForEach(cvWeekDays(), id: \.self) { date in
|
||||
let isTod = cal.isDateInToday(date)
|
||||
let isSel = cal.isDate(date, inSameDayAs: selectedDate)
|
||||
let dots = eventDots(for: date)
|
||||
let items = cvAgendaItems(date)
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack(spacing: 4) {
|
||||
Text(cvDayFmt.string(from: date))
|
||||
@@ -775,12 +809,20 @@ struct CalendarView: View {
|
||||
.font(AppFonts.sans(12, weight: isTod ? .bold : .medium))
|
||||
.foregroundColor(isTod ? AppColors.accent : AppColors.text(cs))
|
||||
}
|
||||
if dots.isEmpty {
|
||||
if items.isEmpty {
|
||||
Text("Free").font(AppFonts.sans(9)).foregroundColor(AppColors.text3(cs))
|
||||
} else {
|
||||
ForEach(dots.indices, id: \.self) { i in
|
||||
RoundedRectangle(cornerRadius: 2).fill(dots[i].opacity(0.5))
|
||||
.frame(maxWidth: .infinity).frame(height: 7)
|
||||
ForEach(Array(items.prefix(4).enumerated()), id: \.offset) { _, item in
|
||||
HStack(spacing: 5) {
|
||||
RoundedRectangle(cornerRadius: 1.5).fill(item.color)
|
||||
.frame(width: 2.5, height: 11)
|
||||
Text(item.title).font(AppFonts.sans(9))
|
||||
.foregroundColor(AppColors.text2(cs)).lineLimit(1)
|
||||
}
|
||||
}
|
||||
if items.count > 4 {
|
||||
Text("+\(items.count - 4) more").font(AppFonts.sans(8))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,7 +892,66 @@ struct CalendarView: View {
|
||||
|
||||
private func cvTimeGrid(days: [Date]) -> some View {
|
||||
let totalH = CGFloat(cvEndH - cvStartH) * cvHourH
|
||||
return ZStack(alignment: .topLeading) {
|
||||
return VStack(spacing: 0) {
|
||||
cvAllDayBand(days: days)
|
||||
cvHourGrid(days: days, totalH: totalH)
|
||||
}
|
||||
}
|
||||
|
||||
/// Untimed tasks, all-day events and the day's workout — these have no slot on
|
||||
/// the hour grid, so they sit in a compact band above it (one column per day).
|
||||
private func cvAllDayItems(_ date: Date) -> [CVTimeItem] {
|
||||
var items: [CVTimeItem] = []
|
||||
if let w = workoutVM.program(for: date) {
|
||||
items.append(CVTimeItem(title: w.name, color: AppColors.blue, startMin: -1, endMin: -1))
|
||||
}
|
||||
for t in taskVM.occurrences(on: date) where !t.isComplete && !t.hasTime {
|
||||
items.append(CVTimeItem(title: t.title, color: t.quadrant.color, startMin: -1, endMin: -1))
|
||||
}
|
||||
for ev in (calStore.isAuthorized ? calStore.events(for: date) : []) where ev.isAllDay {
|
||||
items.append(CVTimeItem(title: ev.title ?? "Event", color: Color(cgColor: ev.calendar.cgColor), startMin: -1, endMin: -1))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
/// Everything happening on a date — all-day items first, then timed — for compact
|
||||
/// agenda listings (e.g. the Week column). Recurrence-aware.
|
||||
private func cvAgendaItems(_ date: Date) -> [CVTimeItem] {
|
||||
(cvAllDayItems(date) + cvTimeItems(date)).sorted { $0.startMin < $1.startMin }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func cvAllDayBand(days: [Date]) -> some View {
|
||||
let perDay = days.map { cvAllDayItems($0) }
|
||||
if perDay.contains(where: { !$0.isEmpty }) {
|
||||
HStack(alignment: .top, spacing: 2) {
|
||||
Text("all-day")
|
||||
.font(AppFonts.mono(7, weight: .semibold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
ForEach(Array(days.enumerated()), id: \.offset) { idx, _ in
|
||||
VStack(spacing: 2) {
|
||||
ForEach(Array(perDay[idx].enumerated()), id: \.offset) { _, item in
|
||||
Text(item.title)
|
||||
.font(AppFonts.sans(8.5, weight: .medium))
|
||||
.foregroundColor(item.color)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 5).padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(item.color.opacity(0.18))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
Divider().background(AppColors.border(cs))
|
||||
}
|
||||
}
|
||||
|
||||
private func cvHourGrid(days: [Date], totalH: CGFloat) -> some View {
|
||||
ZStack(alignment: .topLeading) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(cvStartH...cvEndH, id: \.self) { h in
|
||||
HStack(spacing: 0) {
|
||||
@@ -956,10 +1057,11 @@ struct CalendarView: View {
|
||||
|
||||
private func cvTimeItems(_ date: Date) -> [CVTimeItem] {
|
||||
var items: [CVTimeItem] = []
|
||||
for task in taskVM.tasks where !task.isComplete {
|
||||
guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue }
|
||||
// Recurrence-aware, and only *timed* tasks belong on the hour grid —
|
||||
// untimed tasks are surfaced separately in the all-day band.
|
||||
for task in taskVM.occurrences(on: date) where !task.isComplete && task.hasTime {
|
||||
guard let d = task.dueDate else { continue }
|
||||
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
||||
guard h != 0 || m != 0 else { continue }
|
||||
items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30))
|
||||
}
|
||||
for ev in calStore.isAuthorized ? calStore.events(for: date) : [] {
|
||||
@@ -1540,6 +1642,11 @@ struct DayTimelineView: View {
|
||||
let calEvents: [EKEvent]
|
||||
let workout: WorkoutProgram?
|
||||
var workoutDone: Bool = false // per-DATE completion (from workoutDates), not live sets
|
||||
var workoutMissed: Bool = false
|
||||
var workoutCompensation: Bool = false
|
||||
var onWorkoutDone: (() -> Void)? = nil
|
||||
var onWorkoutMissed: (() -> Void)? = nil
|
||||
var onWorkoutCompensate: (() -> Void)? = nil
|
||||
var onWorkoutTap: (() -> Void)? = nil
|
||||
var onToggle: ((TaskItem) -> Void)? = nil
|
||||
var onPin: ((TaskItem) -> Void)? = nil
|
||||
@@ -1547,9 +1654,11 @@ struct DayTimelineView: View {
|
||||
var onEdit: ((TaskItem) -> Void)? = nil
|
||||
var onDelete: ((TaskItem) -> Void)? = nil
|
||||
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
||||
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
||||
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
||||
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
|
||||
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
|
||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||
// Calendar events (non-subscription only): edit/delete in the real calendar.
|
||||
var onEditEvent: ((EKEvent) -> Void)? = nil
|
||||
var onDeleteEvent: ((EKEvent) -> Void)? = nil
|
||||
@@ -1589,11 +1698,13 @@ struct DayTimelineView: View {
|
||||
// date (workoutDates), never the program's shared live set state.
|
||||
if let w = workout {
|
||||
let done = workoutDone
|
||||
let status = done ? "Done" : workoutMissed ? "Missed" : "Pending"
|
||||
let kind = workoutCompensation ? "Compensation · " : ""
|
||||
out.append(DayTLEntry(
|
||||
timeLabel: "Workout",
|
||||
title: w.name,
|
||||
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
|
||||
color: AppColors.blue,
|
||||
subtitle: "\(kind)\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s") · \(status)",
|
||||
color: workoutMissed && !done ? AppColors.accent : AppColors.blue,
|
||||
isComplete: done, isAllDay: true, sortKey: -2, sortGroup: -1, workoutRef: w
|
||||
))
|
||||
}
|
||||
@@ -1685,6 +1796,9 @@ struct DayTimelineView: View {
|
||||
)
|
||||
if let t = entry.taskRef {
|
||||
row.contextMenu { taskContextMenu(t) }
|
||||
} else if entry.workoutRef != nil,
|
||||
onWorkoutDone != nil || onWorkoutMissed != nil {
|
||||
row.contextMenu { workoutContextMenu }
|
||||
} else if let ev = entry.eventRef,
|
||||
onEditEvent != nil || onDeleteEvent != nil,
|
||||
ev.calendar?.type != .subscription,
|
||||
@@ -1704,15 +1818,38 @@ struct DayTimelineView: View {
|
||||
task: task,
|
||||
onPin: { onPin?($0) },
|
||||
onSetDate: { onSetDate?($0, $1) },
|
||||
onPostponeMinutes: { onPostponeMinutes?($0, $1) },
|
||||
onMove: { onMove?($0, $1) },
|
||||
onSetPriority: { onSetPriority?($0, $1) },
|
||||
onSetCategory: { onSetCategory?($0, $1) },
|
||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit,
|
||||
onDelete: { onDelete?($0) }
|
||||
)
|
||||
}
|
||||
|
||||
// Quick actions for the day's workout — applies to THIS date only.
|
||||
@ViewBuilder
|
||||
private var workoutContextMenu: some View {
|
||||
if let onWorkoutDone {
|
||||
Button { onWorkoutDone() } label: {
|
||||
Label("Mark as Done", systemImage: "checkmark.circle")
|
||||
}
|
||||
}
|
||||
if let onWorkoutMissed {
|
||||
Button { onWorkoutMissed() } label: {
|
||||
Label("Mark as Not Done", systemImage: "xmark.circle")
|
||||
}
|
||||
}
|
||||
if let onWorkoutCompensate {
|
||||
Divider()
|
||||
Button { onWorkoutCompensate() } label: {
|
||||
Label("Move to Rest Day / Compensate", systemImage: "arrow.uturn.forward")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Edit / Delete for a real (non-subscription) calendar event.
|
||||
@ViewBuilder
|
||||
private func eventContextMenu(_ event: EKEvent) -> some View {
|
||||
|
||||
@@ -5,10 +5,11 @@ struct MatrixView: View {
|
||||
@EnvironmentObject var taskVM: TaskViewModel
|
||||
@State private var showAddTask = false
|
||||
@State private var dropTarget: Quadrant? = nil
|
||||
@State private var hideCompleted = false
|
||||
@State private var hideCompleted = true
|
||||
@State private var showUserGuide = false
|
||||
@State private var drillQuadrant: Quadrant? = nil
|
||||
@State private var showDrill = false
|
||||
@State private var editingTask: TaskItem? = nil
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
@@ -56,7 +57,7 @@ struct MatrixView: View {
|
||||
quadrant: .urgent,
|
||||
roman: "I", label: "Urgent & Important",
|
||||
badgeBg: AppColors.accentSoft, badgeColor: AppColors.accent,
|
||||
tasks: taskVM.tasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .urgent).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .urgent,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .urgent); dropTarget = nil } },
|
||||
@@ -64,9 +65,12 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onTapHeader: { drillQuadrant = .urgent; showDrill = true }
|
||||
)
|
||||
@@ -74,7 +78,7 @@ struct MatrixView: View {
|
||||
quadrant: .schedule,
|
||||
roman: "II", label: "Not Urgent & Important",
|
||||
badgeBg: AppColors.yellowSoft, badgeColor: AppColors.yellow,
|
||||
tasks: taskVM.tasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .schedule).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .schedule,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .schedule); dropTarget = nil } },
|
||||
@@ -82,9 +86,12 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onTapHeader: { drillQuadrant = .schedule; showDrill = true }
|
||||
)
|
||||
@@ -96,7 +103,7 @@ struct MatrixView: View {
|
||||
quadrant: .delegate_,
|
||||
roman: "III", label: "Urgent & Unimportant",
|
||||
badgeBg: AppColors.blueSoft, badgeColor: AppColors.blue,
|
||||
tasks: taskVM.tasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .delegate_).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .delegate_,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .delegate_); dropTarget = nil } },
|
||||
@@ -104,9 +111,12 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onTapHeader: { drillQuadrant = .delegate_; showDrill = true }
|
||||
)
|
||||
@@ -114,7 +124,7 @@ struct MatrixView: View {
|
||||
quadrant: .eliminate,
|
||||
roman: "IV", label: "Not Urgent & Unimportant",
|
||||
badgeBg: AppColors.greenSoft, badgeColor: AppColors.green,
|
||||
tasks: taskVM.tasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
|
||||
tasks: taskVM.matrixTasks(for: .eliminate).filter { !hideCompleted || !$0.isComplete },
|
||||
isDropTarget: dropTarget == .eliminate,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onDrop: { id in withAnimation(KisaniSpring.snappy) { taskVM.moveToQuadrant(taskID: id, quadrant: .eliminate); dropTarget = nil } },
|
||||
@@ -122,9 +132,12 @@ struct MatrixView: View {
|
||||
onDropExit: { dropTarget = nil },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onSetDate: { taskVM.setDate($0, date: $1) },
|
||||
onPostponeMinutes: { taskVM.postpone($0, minutes: $1) },
|
||||
onMove: { taskVM.moveToQuadrant(taskID: $0.id, quadrant: $1) },
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onSetPriority: { taskVM.setPriority($0, priority: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) },
|
||||
onTapHeader: { drillQuadrant = .eliminate; showDrill = true }
|
||||
)
|
||||
@@ -151,6 +164,12 @@ struct MatrixView: View {
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(item: $editingTask) { task in
|
||||
TaskEditSheet(task: task)
|
||||
.environmentObject(taskVM)
|
||||
.presentationDetents([.medium, .large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.navigationDestination(isPresented: $showDrill) {
|
||||
if let q = drillQuadrant {
|
||||
QuadrantDetailView(quadrant: q)
|
||||
@@ -185,9 +204,12 @@ struct QuadrantCard: View {
|
||||
let onDropExit: () -> Void
|
||||
var onPin: ((TaskItem) -> Void)? = nil
|
||||
var onSetDate: ((TaskItem, Date?) -> Void)? = nil
|
||||
var onPostponeMinutes: ((TaskItem, Int) -> Void)? = nil
|
||||
var onMove: ((TaskItem, Quadrant) -> Void)? = nil
|
||||
var onSetCategory: ((TaskItem, TaskCategory) -> Void)? = nil
|
||||
var onSetPriority: ((TaskItem, Priority) -> Void)? = nil
|
||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||
var onEdit: ((TaskItem) -> Void)? = nil
|
||||
var onDelete: ((TaskItem) -> Void)? = nil
|
||||
var onTapHeader: (() -> Void)? = nil
|
||||
|
||||
@@ -265,6 +287,15 @@ struct QuadrantCard: View {
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundColor(badgeColor.opacity(0.7))
|
||||
}
|
||||
if task.urgencyOverride != nil {
|
||||
Text("Manual")
|
||||
.font(AppFonts.mono(7, weight: .bold))
|
||||
.foregroundColor(badgeColor)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(badgeBg)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(12, weight: .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
@@ -288,10 +319,13 @@ struct QuadrantCard: View {
|
||||
currentQuadrant: quadrant,
|
||||
onPin: { t in withAnimation(KisaniSpring.snappy) { onPin?(t) } },
|
||||
onSetDate: { onSetDate?($0, $1) },
|
||||
onPostponeMinutes: { onPostponeMinutes?($0, $1) },
|
||||
onMove: { t, q in withAnimation(KisaniSpring.snappy) { onMove?(t, q) } },
|
||||
onSetPriority: { onSetPriority?($0, $1) },
|
||||
onSetCategory: { onSetCategory?($0, $1) },
|
||||
onResetMatrixUrgency: { t in withAnimation(KisaniSpring.snappy) { onResetMatrixUrgency?(t) } },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit.map { cb in { cb($0) } },
|
||||
onDelete: { t in withAnimation(KisaniSpring.snappy) { onDelete?(t) } }
|
||||
)
|
||||
}
|
||||
@@ -408,21 +442,23 @@ struct QuadrantDetailView: View {
|
||||
@State private var showAllCompleted = false
|
||||
private let completedLimit = 5
|
||||
|
||||
// Grouped by the *computed* quadrant (importance × deadline urgency); recurring
|
||||
// tasks contribute their single next-active occurrence.
|
||||
private var overdueTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
return taskVM.matrixTasks(for: quadrant)
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) < startOfDay }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
private var laterTasks: [TaskItem] {
|
||||
let startOfDay = Calendar.current.startOfDay(for: Date())
|
||||
return taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
|
||||
return taskVM.matrixTasks(for: quadrant)
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantFuture) >= startOfDay }
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
private var completedTasks: [TaskItem] {
|
||||
taskVM.tasks
|
||||
.filter { $0.quadrant == quadrant && $0.isComplete }
|
||||
taskVM.matrixTasks(for: quadrant)
|
||||
.filter { $0.isComplete }
|
||||
.sorted { ($0.completedAt ?? .distantPast) > ($1.completedAt ?? .distantPast) }
|
||||
}
|
||||
private var allEmpty: Bool { overdueTasks.isEmpty && laterTasks.isEmpty && completedTasks.isEmpty }
|
||||
@@ -444,9 +480,11 @@ struct QuadrantDetailView: View {
|
||||
currentQuadrant: quadrant,
|
||||
onPin: { taskVM.pin($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) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
@@ -469,9 +507,11 @@ struct QuadrantDetailView: View {
|
||||
currentQuadrant: quadrant,
|
||||
onPin: { taskVM.pin($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) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
@@ -495,9 +535,11 @@ struct QuadrantDetailView: View {
|
||||
currentQuadrant: quadrant,
|
||||
onPin: { taskVM.pin($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) },
|
||||
onLiveActivity:{ LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($0) }
|
||||
@@ -620,12 +662,23 @@ private struct QDTaskRow: View {
|
||||
|
||||
Button(action: onTap) {
|
||||
HStack(alignment: .top) {
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||||
.lineLimit(2)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if task.urgencyOverride != nil {
|
||||
Text("Manual")
|
||||
.font(AppFonts.mono(8, weight: .bold))
|
||||
.foregroundColor(quadrant.color)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 2)
|
||||
.background(quadrant.softColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
Text(task.title)
|
||||
.font(AppFonts.sans(14, weight: task.isComplete ? .regular : .medium))
|
||||
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||||
.lineLimit(2)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 3) {
|
||||
if let d = task.dueDate {
|
||||
@@ -675,6 +728,7 @@ struct TaskEditSheet: View {
|
||||
@State private var hasTime: Bool
|
||||
@State private var isRecurring: Bool
|
||||
@State private var recurrenceLabel: String?
|
||||
@State private var recurrenceEnd: Date?
|
||||
@State private var endDate: Date?
|
||||
@State private var isAllDay: Bool
|
||||
@State private var reminderDate: Date?
|
||||
@@ -688,6 +742,7 @@ struct TaskEditSheet: View {
|
||||
_hasTime = State(initialValue: task.hasTime)
|
||||
_isRecurring = State(initialValue: task.isRecurring)
|
||||
_recurrenceLabel = State(initialValue: task.recurrenceLabel)
|
||||
_recurrenceEnd = State(initialValue: task.recurrenceEnd)
|
||||
_endDate = State(initialValue: task.endDate)
|
||||
_isAllDay = State(initialValue: task.isAllDay)
|
||||
_reminderDate = State(initialValue: task.reminderDate)
|
||||
@@ -838,7 +893,8 @@ struct TaskEditSheet: View {
|
||||
endDate: $endDate,
|
||||
isAllDay: $isAllDay,
|
||||
reminderDate: $reminderDate,
|
||||
constantReminder: $constantReminder
|
||||
constantReminder: $constantReminder,
|
||||
recurrenceEnd: $recurrenceEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -849,7 +905,7 @@ struct TaskEditSheet: View {
|
||||
taskVM.updateTask(task, title: t, dueDate: dueDate, hasTime: hasTime,
|
||||
isRecurring: isRecurring, recurrenceLabel: recurrenceLabel,
|
||||
endDate: endDate, isAllDay: isAllDay, reminderDate: reminderDate,
|
||||
constantReminder: constantReminder)
|
||||
constantReminder: constantReminder, recurrenceEnd: recurrenceEnd)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ struct OnboardingView: View {
|
||||
.foregroundColor(AppColors.accent)
|
||||
}
|
||||
VStack(spacing: 6) {
|
||||
Text("Welcome to KisaniCal")
|
||||
Text("Welcome to Wenza")
|
||||
.font(AppFonts.sans(26, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("Tasks, calendar, and workouts\n— all in one place.")
|
||||
|
||||
@@ -22,6 +22,8 @@ struct SettingsView: View {
|
||||
@AppStorage("googleCalSync") private var googleCalSync = false
|
||||
@AppStorage("iCloudCalSync") private var iCloudCalSync = true
|
||||
@AppStorage("streakGoal") private var streakGoal = 5
|
||||
// App-Group store so NotificationManager (which reads UserDefaults.kisani) sees it.
|
||||
@AppStorage("kisani.periodMilestones", store: UserDefaults.kisani) private var periodMilestones = true
|
||||
|
||||
// Sheet presentation
|
||||
@State private var showProfile = false
|
||||
@@ -102,6 +104,11 @@ struct SettingsView: View {
|
||||
SttNavRow(icon: "menucard", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Tab Bar", value: "\(showMatrixTab && showWorkoutTab ? "5" : showMatrixTab || showWorkoutTab ? "4" : "3") tabs") { showTabBar = true }
|
||||
SttNavRow(icon: "paintpalette", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Appearance", value: appearanceLabel) { showAppearance = true }
|
||||
SttToggleRow(icon: "bell", bg: AppColors.accentSoft, fg: AppColors.accent, label: "Sounds & Notifications", isOn: $soundsOn)
|
||||
SttToggleRow(icon: "calendar.badge.clock", bg: AppColors.blueSoft, fg: AppColors.blue, label: "Time Milestones",
|
||||
isOn: Binding(
|
||||
get: { periodMilestones },
|
||||
set: { periodMilestones = $0
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM) }))
|
||||
SttNavRow(icon: "clock", bg: AppColors.greenSoft, fg: AppColors.green, label: "Date & Time", value: dateLabel) { showDateTime = true }
|
||||
SttNavRow(icon: "puzzlepiece", bg: AppColors.yellowSoft, fg: AppColors.yellow, label: "Widgets") { showWidgets = true }
|
||||
SttNavRow(icon: "gearshape", bg: AppColors.surface3(cs), label: "General", isLast: true) { showGeneral = true }
|
||||
@@ -456,7 +463,7 @@ private struct WidgetsSheet: View {
|
||||
}
|
||||
.padding(12).cardStyle()
|
||||
}
|
||||
Text("Long-press your home screen → tap + → search Kisani Cal to add widgets.")
|
||||
Text("Long-press your home screen → tap + → search Wenza to add widgets.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.top, 4)
|
||||
}
|
||||
@@ -527,7 +534,7 @@ private struct ImportSheet: View {
|
||||
.padding(.horizontal, 14).padding(.vertical, 12)
|
||||
}
|
||||
.cardStyle().padding(.horizontal, 20)
|
||||
Text("Enabling integrations will sync events to your Kisani calendar.")
|
||||
Text("Enabling integrations will sync events to your Wenza calendar.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.horizontal, 20).padding(.top, 12)
|
||||
Spacer()
|
||||
@@ -957,10 +964,10 @@ private struct HelpSheet: View {
|
||||
SheetHeader(title: "Help & Feedback") { dismiss() }
|
||||
VStack(spacing: 10) {
|
||||
HelpRow(icon: "envelope", label: "Send Feedback", sub: "Report a bug or suggest a feature") {
|
||||
if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Kisani%20Cal%20Feedback") { openURL(url) }
|
||||
if let url = URL(string: "mailto:feedback@kisanicaI.app?subject=Wenza%20Feedback") { openURL(url) }
|
||||
}
|
||||
HelpRow(icon: "questionmark.circle", label: "FAQ", sub: "Frequently asked questions") {}
|
||||
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Kisani Cal") {}
|
||||
HelpRow(icon: "book", label: "User Guide", sub: "Learn how to use Wenza") {}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
Spacer()
|
||||
@@ -1051,7 +1058,7 @@ private struct ProfileStatsSheet: View {
|
||||
return taskVM.tasks.filter { $0.isComplete && ($0.completedAt ?? .distantPast) >= start }.count
|
||||
}
|
||||
private var urgentOpen: Int {
|
||||
taskVM.tasks.filter { !$0.isComplete && $0.quadrant == .urgent }.count
|
||||
taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count
|
||||
}
|
||||
|
||||
// ── 7-day task activity ──
|
||||
@@ -1140,10 +1147,10 @@ private struct ProfileStatsSheet: View {
|
||||
|
||||
// Quadrant breakdown
|
||||
HStack(spacing: 8) {
|
||||
QuadStat(label: "Urgent", count: taskVM.tasks.filter { $0.quadrant == .urgent && !$0.isComplete }.count, color: AppColors.accent)
|
||||
QuadStat(label: "Schedule", count: taskVM.tasks.filter { $0.quadrant == .schedule && !$0.isComplete }.count, color: AppColors.yellow)
|
||||
QuadStat(label: "Delegate", count: taskVM.tasks.filter { $0.quadrant == .delegate_ && !$0.isComplete }.count, color: AppColors.blue)
|
||||
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { $0.quadrant == .eliminate && !$0.isComplete }.count, color: AppColors.green)
|
||||
QuadStat(label: "Urgent", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .urgent }.count, color: AppColors.accent)
|
||||
QuadStat(label: "Schedule", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .schedule }.count, color: AppColors.yellow)
|
||||
QuadStat(label: "Delegate", count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .delegate_ }.count, color: AppColors.blue)
|
||||
QuadStat(label: "Eliminate",count: taskVM.tasks.filter { !$0.isComplete && taskVM.displayQuadrant($0) == .eliminate }.count, color: AppColors.green)
|
||||
}
|
||||
}
|
||||
.padding(14).background(AppColors.surface(cs))
|
||||
@@ -1343,7 +1350,7 @@ private struct AboutSheet: View {
|
||||
Image(systemName: "calendar.badge.clock")
|
||||
.font(.system(size: 48, weight: .light)).foregroundColor(AppColors.accent)
|
||||
VStack(spacing: 4) {
|
||||
Text("Kisani Cal").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Wenza").font(AppFonts.sans(20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Version 1.0.0").font(AppFonts.mono(11)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Text("A calm, focused productivity app for tasks, calendar, and fitness.")
|
||||
|
||||
@@ -20,14 +20,11 @@ struct SplashView: View {
|
||||
.scaleEffect(iconScale)
|
||||
.opacity(iconOpacity)
|
||||
|
||||
// kisaniCAL. — single line, orange dot
|
||||
// wenza. — single line, orange dot
|
||||
HStack(spacing: 0) {
|
||||
Text("kisani")
|
||||
Text("wenza")
|
||||
.font(AppFonts.mono(22, weight: .regular))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("CAL")
|
||||
.font(AppFonts.mono(22, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text(".")
|
||||
.font(AppFonts.mono(22, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
|
||||
@@ -1,4 +1,27 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
/// Tracks one event for the Event Countdown (Bars) widget via App Group storage.
|
||||
/// The widget reads these keys directly — no manual widget configuration needed.
|
||||
enum CountdownTracker {
|
||||
static let idKey = "kisani.widget.trackedEvent"
|
||||
static let sinceKey = "kisani.widget.trackedSince"
|
||||
|
||||
static func isTracked(_ task: TaskItem) -> Bool {
|
||||
UserDefaults.kisani.string(forKey: idKey) == task.id.uuidString
|
||||
}
|
||||
|
||||
static func toggle(_ task: TaskItem) {
|
||||
if isTracked(task) {
|
||||
UserDefaults.kisani.removeObject(forKey: idKey)
|
||||
UserDefaults.kisani.removeObject(forKey: sinceKey)
|
||||
} else {
|
||||
UserDefaults.kisani.set(task.id.uuidString, forKey: idKey)
|
||||
UserDefaults.kisani.set(Date(), forKey: sinceKey) // Mode A anchor
|
||||
}
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
}
|
||||
}
|
||||
|
||||
/// The unified long-press menu for a task, shared across Today, Matrix, and Calendar.
|
||||
/// Pass only the closures a given screen needs; the rest default to no-ops.
|
||||
@@ -9,9 +32,11 @@ struct TaskMenuItems: View {
|
||||
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
var onResetMatrixUrgency: ((TaskItem) -> Void)? = nil
|
||||
var onLiveActivity:(TaskItem) -> Void = { LiveActivityManager.toggle(for: $0) }
|
||||
var onEdit: ((TaskItem) -> Void)? = nil
|
||||
var onDelete: (TaskItem) -> Void = { _ in }
|
||||
@@ -24,29 +49,60 @@ struct TaskMenuItems: View {
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button { onSetDate(task, cal.startOfDay(for: Date())) } label: {
|
||||
Label("Today", systemImage: "sun.max")
|
||||
Button { onSetDate(task, preservingTime(on: Date())) } label: {
|
||||
Label("Today", systemImage: "calendar")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
||||
Label("Tomorrow", systemImage: "sun.max")
|
||||
}
|
||||
Button { onSetDate(task, preservingTime(on: nextMonday())) } label: {
|
||||
Label("Next Monday", systemImage: "calendar.badge.clock")
|
||||
}
|
||||
Button {
|
||||
let d = cal.date(byAdding: .day, value: 1, to: Date())!
|
||||
onSetDate(task, cal.startOfDay(for: d))
|
||||
} label: { Label("Tomorrow", systemImage: "sunrise") }
|
||||
Button {
|
||||
let d = cal.date(byAdding: .weekOfYear, value: 1, to: Date())!
|
||||
onSetDate(task, cal.startOfDay(for: d))
|
||||
} label: { Label("Next Week", systemImage: "calendar") }
|
||||
Divider()
|
||||
if let onEdit {
|
||||
Button { onEdit(task) } label: {
|
||||
Label("Pick Date", systemImage: "calendar.badge.plus")
|
||||
}
|
||||
}
|
||||
Button { onSetDate(task, nil) } label: {
|
||||
Label("Remove Date", systemImage: "xmark.circle")
|
||||
Label("Clear", systemImage: "xmark.square")
|
||||
}
|
||||
} label: { Label("Date", systemImage: "calendar.badge.clock") }
|
||||
|
||||
Menu {
|
||||
Button { onPostponeMinutes(task, 15) } label: {
|
||||
Label("Snooze 15 Minutes", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 30) } label: {
|
||||
Label("Snooze 30 Minutes", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 60) } label: {
|
||||
Label("Snooze 1 Hour", systemImage: "clock")
|
||||
}
|
||||
Button { onPostponeMinutes(task, 120) } label: {
|
||||
Label("Snooze 2 Hours", systemImage: "clock")
|
||||
}
|
||||
Divider()
|
||||
Button { onSetDate(task, preservingTime(on: dayOffset(1))) } label: {
|
||||
Label("Tomorrow", systemImage: "sun.max")
|
||||
}
|
||||
} label: { Label("Postpone", systemImage: "clock.arrow.circlepath") }
|
||||
|
||||
Menu {
|
||||
// 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: { Text(q.rawValue) }
|
||||
Button { onMove(task, q) } label: {
|
||||
Label(q.matrixLabel, systemImage: "arrow.right")
|
||||
}
|
||||
}
|
||||
} label: { Label("Move", systemImage: "arrow.right.square") }
|
||||
|
||||
if task.urgencyOverride != nil, let onResetMatrixUrgency {
|
||||
Button { onResetMatrixUrgency(task) } label: {
|
||||
Label("Reset Matrix Urgency", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Menu {
|
||||
ForEach(Priority.allCases) { p in
|
||||
Button { onSetPriority(task, p) } label: {
|
||||
@@ -64,7 +120,12 @@ struct TaskMenuItems: View {
|
||||
} label: { Label("Tags", systemImage: "tag") }
|
||||
|
||||
Button { onLiveActivity(task) } label: {
|
||||
Label("Add to Live Activity", systemImage: "pin.circle")
|
||||
Label(liveActivityTitle, systemImage: liveActivityIcon)
|
||||
}
|
||||
|
||||
Button { CountdownTracker.toggle(task) } label: {
|
||||
Label(CountdownTracker.isTracked(task) ? "Stop Tracking Countdown" : "Track Countdown",
|
||||
systemImage: "timer")
|
||||
}
|
||||
|
||||
if let onEdit {
|
||||
@@ -77,4 +138,36 @@ struct TaskMenuItems: View {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
private var liveActivityTitle: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "Remove from Live Activity"
|
||||
}
|
||||
return "Add to Live Activity"
|
||||
}
|
||||
|
||||
private var liveActivityIcon: String {
|
||||
if #available(iOS 16.1, *), LiveActivityManager.isActive(taskID: task.id.uuidString) {
|
||||
return "pin.slash"
|
||||
}
|
||||
return "pin.circle"
|
||||
}
|
||||
|
||||
private func dayOffset(_ value: Int) -> Date {
|
||||
cal.date(byAdding: .day, value: value, to: Date()) ?? Date()
|
||||
}
|
||||
|
||||
private func nextMonday() -> Date {
|
||||
var comps = DateComponents()
|
||||
comps.weekday = 2
|
||||
return cal.nextDate(after: Date(), matching: comps, matchingPolicy: .nextTimePreservingSmallerComponents)
|
||||
?? dayOffset(7)
|
||||
}
|
||||
|
||||
private func preservingTime(on day: Date) -> Date {
|
||||
let base = cal.startOfDay(for: day)
|
||||
guard task.hasTime, let due = task.dueDate else { return base }
|
||||
let parts = cal.dateComponents([.hour, .minute], from: due)
|
||||
return cal.date(bySettingHour: parts.hour ?? 0, minute: parts.minute ?? 0, second: 0, of: base) ?? base
|
||||
}
|
||||
}
|
||||
|
||||
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 editingTask: TaskItem? = nil
|
||||
@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 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 {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
ScrollView(showsIndicators: false) {
|
||||
@@ -36,7 +65,7 @@ struct TodayView: View {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
Menu {
|
||||
Button { } label: { Label("Background", systemImage: "paintbrush") }
|
||||
Button { showBackgroundSheet = true } label: { Label("Background", systemImage: "paintbrush") }
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { hideCompleted.toggle() }
|
||||
} label: {
|
||||
@@ -44,8 +73,8 @@ struct TodayView: View {
|
||||
systemImage: hideCompleted ? "checkmark.circle" : "checkmark.circle.badge.xmark")
|
||||
}
|
||||
Divider()
|
||||
Button { } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
|
||||
Button { } label: { Label("Select", systemImage: "checkmark.circle") }
|
||||
Button { showGroupSortSheet = true } label: { Label("Group & Sort", systemImage: "arrow.up.arrow.down") }
|
||||
Button { showSelectSheet = true } label: { Label("Select", systemImage: "checkmark.circle") }
|
||||
} label: {
|
||||
Image(systemName: "line.3.horizontal")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
@@ -81,6 +110,7 @@ struct TodayView: View {
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
if groupMode == "date" {
|
||||
// ── Today timeline: active → completed (green) ──
|
||||
let activeTasks = taskVM.todayTasks
|
||||
let completedTasks = hideCompleted ? [] : taskVM.completedTodayTasks
|
||||
@@ -91,6 +121,14 @@ struct TodayView: View {
|
||||
calEvents: todayCalEvents,
|
||||
workout: workoutVM.program(for: Date()),
|
||||
workoutDone: workoutVM.isWorkoutComplete(on: Date()),
|
||||
workoutMissed: workoutVM.isWorkoutMissed(on: Date()),
|
||||
workoutCompensation: workoutVM.isCompensation(on: Date()),
|
||||
onWorkoutDone: { workoutVM.markWorkoutDone(on: Date()) },
|
||||
onWorkoutMissed: { workoutVM.markWorkoutMissed(on: Date()) },
|
||||
onWorkoutCompensate: {
|
||||
workoutVM.markWorkoutMissed(on: Date())
|
||||
workoutVM.promptCompensation(forMissed: Date())
|
||||
},
|
||||
onWorkoutTap: {
|
||||
if let w = workoutVM.program(for: Date()), workoutVM.activeProgramId != w.id {
|
||||
workoutVM.switchProgram(to: w.id)
|
||||
@@ -103,9 +141,11 @@ struct TodayView: View {
|
||||
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) }
|
||||
onSetCategory: { taskVM.setCategory($0, category: $1) },
|
||||
onResetMatrixUrgency: { taskVM.resetMatrixUrgencyOverride($0) }
|
||||
)
|
||||
.padding(.horizontal, 18)
|
||||
|
||||
@@ -118,28 +158,79 @@ struct TodayView: View {
|
||||
onPostponeTask: { taskVM.postpone($0) },
|
||||
onPin: { taskVM.pin($0) },
|
||||
onEdit: { editingTask = $0 },
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
// ── Next 3 Days ──
|
||||
let next3 = taskVM.next3DaysTasks
|
||||
if !next3.isEmpty {
|
||||
Next3DaysSection(tasksByDay: next3, onToggle: { taskVM.toggle($0) })
|
||||
}
|
||||
|
||||
// ── Upcoming (4+ days) ──
|
||||
if !taskVM.upcomingTasks.isEmpty {
|
||||
// ── Tomorrow ──
|
||||
if !taskVM.tomorrowTasks.isEmpty {
|
||||
UpcomingSection(
|
||||
tasks: taskVM.upcomingTasks,
|
||||
title: "Tomorrow",
|
||||
tasks: taskVM.tomorrowTasks,
|
||||
onToggle: { taskVM.toggle($0) },
|
||||
onPostpone: { taskVM.postpone($0) },
|
||||
onDelete: { taskVM.delete($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) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Next 7 Days (2–7 days out) ──
|
||||
if !taskVM.next7DaysTasks.isEmpty {
|
||||
UpcomingSection(
|
||||
title: "Next 7 Days",
|
||||
tasks: taskVM.next7DaysTasks,
|
||||
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) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Later (beyond 7 days) ──
|
||||
if !taskVM.laterTasks.isEmpty {
|
||||
UpcomingSection(
|
||||
title: "Later",
|
||||
tasks: taskVM.laterTasks,
|
||||
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) }
|
||||
)
|
||||
}
|
||||
|
||||
} else {
|
||||
groupedTaskSections
|
||||
}
|
||||
|
||||
// ── Completed (all, collapsible) ──
|
||||
if !taskVM.completedTasks.isEmpty {
|
||||
CompletedSection(
|
||||
@@ -150,9 +241,8 @@ struct TodayView: View {
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
let hasAnything = !taskVM.overdueTasks.isEmpty || !taskVM.todayTasks.isEmpty
|
||||
|| !taskVM.upcomingTasks.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
||||
|| !taskVM.next3DaysTasks.isEmpty || !todayCalEvents.isEmpty
|
||||
let hasAnything = !taskVM.allActiveRows.isEmpty || !taskVM.completedTodayTasks.isEmpty
|
||||
|| !todayCalEvents.isEmpty
|
||||
|| workoutVM.program(for: Date()) != nil
|
||||
if !hasAnything {
|
||||
VStack(spacing: 6) {
|
||||
@@ -192,13 +282,28 @@ struct TodayView: View {
|
||||
.zIndex(10)
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
.background(TodayBackground.gradient(bgStyle, cs).ignoresSafeArea())
|
||||
.sheet(isPresented: $showAddTask) {
|
||||
AddTaskSheet()
|
||||
.environmentObject(taskVM)
|
||||
.presentationDetents([.height(150)])
|
||||
.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) {
|
||||
WorkoutSessionSheet()
|
||||
.environmentObject(workoutVM)
|
||||
@@ -486,6 +591,12 @@ struct OverdueCard: View {
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onEdit: (TaskItem) -> Void = { _ in }
|
||||
var onDelete: (TaskItem) -> Void = { _ in }
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
var onResetMatrixUrgency: (TaskItem) -> Void = { _ in }
|
||||
|
||||
@State private var expanded = true
|
||||
|
||||
@@ -573,25 +684,26 @@ struct OverdueCard: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func taskMenu(_ task: TaskItem) -> some View {
|
||||
Button { withAnimation { onToggle(task) } } label: {
|
||||
Button { withAnimation(KisaniSpring.bounce) { onToggle(task) } } label: {
|
||||
Label("Mark Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { onPostponeTask(task) } label: {
|
||||
Button { withAnimation(KisaniSpring.snappy) { onPostponeTask(task) } } label: {
|
||||
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Button { onPin(task) } label: {
|
||||
Label(task.isPinned ? "Unpin" : "Pin", systemImage: task.isPinned ? "pin.slash" : "pin")
|
||||
}
|
||||
Button { onEdit(task) } label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
ShareLink(item: task.title) {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { onDelete(task) } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
onPin: onPin,
|
||||
onSetDate: onSetDate,
|
||||
onPostponeMinutes: onPostponeMinutes,
|
||||
onMove: onMove,
|
||||
onSetPriority: onSetPriority,
|
||||
onSetCategory: onSetCategory,
|
||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit,
|
||||
onDelete: onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,12 +1001,21 @@ private struct SessionSectionHeader: View {
|
||||
|
||||
struct UpcomingSection: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
var title: String = "Upcoming"
|
||||
let tasks: [TaskItem]
|
||||
let onToggle: (TaskItem) -> Void
|
||||
let onPostpone: (TaskItem) -> Void
|
||||
var onPin: (TaskItem) -> Void = { _ in }
|
||||
var onEdit: (TaskItem) -> Void = { _ in }
|
||||
let onDelete: (TaskItem) -> Void
|
||||
var onSetDate: (TaskItem, Date?) -> Void = { _, _ in }
|
||||
var onPostponeMinutes: (TaskItem, Int) -> Void = { _, _ in }
|
||||
var onMove: (TaskItem, Quadrant) -> Void = { _, _ in }
|
||||
var onSetPriority: (TaskItem, Priority) -> Void = { _, _ in }
|
||||
var onSetCategory: (TaskItem, TaskCategory) -> Void = { _, _ in }
|
||||
var onResetMatrixUrgency: (TaskItem) -> Void = { _ in }
|
||||
|
||||
@AppStorage("upcomingCollapsed") private var collapsed = false
|
||||
@State private var collapsed = false
|
||||
@State private var showAll = false
|
||||
|
||||
private let pageSize = 5
|
||||
@@ -908,7 +1029,7 @@ struct UpcomingSection: View {
|
||||
|
||||
// ── Header (tappable to collapse) ──
|
||||
HStack(spacing: 6) {
|
||||
Text("Upcoming")
|
||||
Text(title)
|
||||
.font(AppFonts.sans(10.5, weight: .semibold))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
|
||||
@@ -945,13 +1066,23 @@ struct UpcomingSection: View {
|
||||
withAnimation(KisaniSpring.snappy) { onToggle(task) }
|
||||
}
|
||||
.contextMenu {
|
||||
Button { withAnimation { onPostpone(task) } } label: {
|
||||
Button { withAnimation(KisaniSpring.snappy) { onPostpone(task) } } label: {
|
||||
Label("Postpone 1 Day", systemImage: "clock.arrow.circlepath")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) { withAnimation { onDelete(task) } } label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
TaskMenuItems(
|
||||
task: task,
|
||||
onPin: onPin,
|
||||
onSetDate: onSetDate,
|
||||
onPostponeMinutes: onPostponeMinutes,
|
||||
onMove: onMove,
|
||||
onSetPriority: onSetPriority,
|
||||
onSetCategory: onSetCategory,
|
||||
onResetMatrixUrgency: onResetMatrixUrgency,
|
||||
onLiveActivity: { LiveActivityManager.toggle(for: $0) },
|
||||
onEdit: onEdit,
|
||||
onDelete: { task in withAnimation(KisaniSpring.snappy) { onDelete(task) } }
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 5)
|
||||
@@ -1146,24 +1277,35 @@ struct TaskRowView: View {
|
||||
let task: TaskItem
|
||||
let onToggle: () -> Void
|
||||
|
||||
@State private var showCompleteConfirm = false
|
||||
|
||||
private let dueFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "MMM d"; return f
|
||||
}()
|
||||
|
||||
/// A task scheduled for a day after today — completing one of these is easy to do
|
||||
/// by accident from the Next 7 Days / Later sections, so we confirm first.
|
||||
private var isFutureTask: Bool {
|
||||
guard let d = task.dueDate else { return false }
|
||||
let cal = Calendar.current
|
||||
return cal.startOfDay(for: d) > cal.startOfDay(for: Date())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Button(action: onToggle) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(task.quadrant.softColor)
|
||||
.frame(width: 22, height: 22)
|
||||
Circle()
|
||||
.stroke(task.quadrant.color, lineWidth: 2)
|
||||
.frame(width: 22, height: 22)
|
||||
Button {
|
||||
if !task.isComplete && isFutureTask {
|
||||
showCompleteConfirm = true
|
||||
} else {
|
||||
onToggle()
|
||||
}
|
||||
.frame(width: 36, height: 44)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Circle())
|
||||
} label: {
|
||||
Circle()
|
||||
.strokeBorder(task.quadrant.color, lineWidth: 1.5)
|
||||
.frame(width: 18, height: 18)
|
||||
.frame(width: 36, height: 44)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.82))
|
||||
|
||||
@@ -1173,7 +1315,7 @@ struct TaskRowView: View {
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
if let d = task.dueDate, showDetails {
|
||||
HStack(spacing: 4) {
|
||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · Annual" : "")")
|
||||
Text("\(dueFmt.string(from: d))\(task.isRecurring ? " · \(task.recurrenceLabel ?? "Repeat")" : "")")
|
||||
.font(AppFonts.mono(9.5))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
Text("·")
|
||||
@@ -1188,6 +1330,20 @@ struct TaskRowView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// ⏰ reminder · 🔁 recurring indicators
|
||||
HStack(spacing: 5) {
|
||||
if task.reminderDate != nil || task.constantReminder {
|
||||
Image(systemName: "alarm")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
if task.isRecurring {
|
||||
Image(systemName: "repeat")
|
||||
.font(.system(size: 11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
}
|
||||
|
||||
TagChip(
|
||||
text: task.category.rawValue,
|
||||
color: task.quadrant.color,
|
||||
@@ -1205,6 +1361,18 @@ struct TaskRowView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small)
|
||||
.stroke(AppColors.border(cs), lineWidth: 1))
|
||||
.confirmationDialog(
|
||||
"Complete this task?",
|
||||
isPresented: $showCompleteConfirm,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Mark Complete") { onToggle() }
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
if let d = task.dueDate {
|
||||
Text("“\(task.title)” is scheduled for \(dueFmt.string(from: d)).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func countdownLabel(to date: Date) -> String {
|
||||
@@ -1304,6 +1472,7 @@ struct NLParsed {
|
||||
var tokenRanges: [NSRange] = []
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var recurrenceEnd: Date? = nil
|
||||
var endDate: Date? = nil
|
||||
var isAllDay: Bool = false
|
||||
var isBirthday: Bool = false
|
||||
@@ -1640,7 +1809,7 @@ struct AddTaskSheet: View {
|
||||
|
||||
@State private var rawText = ""
|
||||
@State private var parsed = NLParsed()
|
||||
@State private var selectedQuadrant: Quadrant
|
||||
@State private var selectedPriority: Priority = .none
|
||||
@State private var selectedCategory: TaskCategory = .reminder
|
||||
@State private var reminderDate: Date? = nil
|
||||
@State private var constantReminder = false
|
||||
@@ -1652,9 +1821,11 @@ struct AddTaskSheet: View {
|
||||
init(prefilledDate: Date? = nil, prefilledQuadrant: Quadrant = .urgent) {
|
||||
self.prefilledDate = prefilledDate
|
||||
self.prefilledQuadrant = prefilledQuadrant
|
||||
_selectedQuadrant = State(initialValue: prefilledQuadrant)
|
||||
// Adding from a Matrix quadrant: seed the Priority that lands the task there.
|
||||
let importantRow = (prefilledQuadrant == .urgent || prefilledQuadrant == .schedule)
|
||||
_selectedPriority = State(initialValue: importantRow ? .high : .none)
|
||||
var p = NLParsed()
|
||||
p.date = prefilledDate ?? Calendar.current.startOfDay(for: Date())
|
||||
p.date = prefilledDate
|
||||
_parsed = State(initialValue: p)
|
||||
}
|
||||
|
||||
@@ -1672,7 +1843,7 @@ struct AddTaskSheet: View {
|
||||
.frame(minHeight: 36, maxHeight: 72)
|
||||
.onChange(of: rawText) { _ in
|
||||
var p = NLTaskParser.parse(rawText)
|
||||
if p.date == nil { p.date = Calendar.current.startOfDay(for: Date()) }
|
||||
if p.date == nil { p.date = prefilledDate }
|
||||
withAnimation(KisaniSpring.micro) {
|
||||
parsed = p
|
||||
if p.isBirthday { selectedCategory = .birthday }
|
||||
@@ -1720,27 +1891,18 @@ struct AddTaskSheet: View {
|
||||
|
||||
Spacer(minLength: 4)
|
||||
|
||||
// Quadrant
|
||||
// Priority — drives Matrix placement (importance axis), TickTick-style
|
||||
Menu {
|
||||
ForEach(Quadrant.allCases) { q in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedQuadrant = q } } label: {
|
||||
Label {
|
||||
Text(q.matrixLabel)
|
||||
} icon: {
|
||||
Image(uiImage: quadrantBadgeImage(q))
|
||||
}
|
||||
ForEach(Priority.allCases) { p in
|
||||
Button { withAnimation(KisaniSpring.micro) { selectedPriority = p } } label: {
|
||||
Label(p.label, systemImage: selectedPriority == p ? "checkmark" : "flag")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(selectedQuadrant.roman)
|
||||
.font(AppFonts.mono(8, weight: .heavy))
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 14, height: 14)
|
||||
.background(selectedQuadrant.color)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.frame(width: 42, height: 44)
|
||||
Image(systemName: selectedPriority == .none ? "flag" : "flag.fill")
|
||||
.font(.system(size: 15))
|
||||
.foregroundColor(selectedPriority == .none ? AppColors.text3(cs) : selectedPriority.color)
|
||||
.frame(width: 42, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -1807,7 +1969,8 @@ struct AddTaskSheet: View {
|
||||
endDate: $parsed.endDate,
|
||||
isAllDay: $parsed.isAllDay,
|
||||
reminderDate: $reminderDate,
|
||||
constantReminder: $constantReminder
|
||||
constantReminder: $constantReminder,
|
||||
recurrenceEnd: $parsed.recurrenceEnd
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1815,34 +1978,14 @@ struct AddTaskSheet: View {
|
||||
private func submit() {
|
||||
guard canAdd else { return }
|
||||
taskVM.addTask(title: cleanTitle, dueDate: parsed.date, hasTime: parsed.hasTime,
|
||||
quadrant: selectedQuadrant, category: selectedCategory,
|
||||
quadrant: prefilledQuadrant, category: selectedCategory,
|
||||
isRecurring: parsed.isRecurring, recurrenceLabel: parsed.recurrenceLabel,
|
||||
endDate: parsed.endDate, isAllDay: parsed.isAllDay,
|
||||
reminderDate: reminderDate, constantReminder: constantReminder)
|
||||
priority: selectedPriority,
|
||||
reminderDate: reminderDate, constantReminder: constantReminder,
|
||||
recurrenceEnd: parsed.recurrenceEnd)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func quadrantBadgeImage(_ q: Quadrant) -> UIImage {
|
||||
let uiColor: UIColor
|
||||
switch q {
|
||||
case .urgent: uiColor = UIColor(AppColors.accent)
|
||||
case .schedule: uiColor = UIColor(AppColors.yellow)
|
||||
case .delegate_: uiColor = UIColor(AppColors.blue)
|
||||
case .eliminate: uiColor = UIColor(AppColors.green)
|
||||
}
|
||||
let size = CGSize(width: 20, height: 20)
|
||||
let renderer = UIGraphicsImageRenderer(size: size)
|
||||
let img = renderer.image { ctx in
|
||||
uiColor.setFill()
|
||||
UIBezierPath(ovalIn: CGRect(origin: .zero, size: size)).fill()
|
||||
let label = q.roman
|
||||
let font = UIFont.monospacedSystemFont(ofSize: 8, weight: .heavy)
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: UIColor.white]
|
||||
let ts = label.size(withAttributes: attrs)
|
||||
label.draw(at: CGPoint(x: (size.width - ts.width) / 2, y: (size.height - ts.height) / 2), withAttributes: attrs)
|
||||
}
|
||||
return img.withRenderingMode(.alwaysOriginal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Task Date Picker Sheet
|
||||
@@ -1852,6 +1995,7 @@ struct TaskDatePickerSheet: View {
|
||||
@Binding var hasTime: Bool
|
||||
@Binding var isRecurring: Bool
|
||||
@Binding var recurrenceLabel: String?
|
||||
@Binding var recurrenceEnd: Date?
|
||||
@Binding var endDate: Date?
|
||||
@Binding var isAllDay: Bool
|
||||
@Binding var reminderDate: Date?
|
||||
@@ -1865,17 +2009,16 @@ struct TaskDatePickerSheet: View {
|
||||
@State private var localTime: Date?
|
||||
@State private var showTimePicker = false
|
||||
@State private var showReminderPicker = false
|
||||
// Reminder lead-time, expressed as days before the task date (nil = no reminder).
|
||||
// Reminder lead-time, expressed as MINUTES before the task time (nil = no reminder).
|
||||
@State private var localReminderOffset: Int?
|
||||
@State private var localReminderTime: Date
|
||||
@State private var localConstant: Bool
|
||||
// Custom wheel state (draft — committed to the reminder only on "Add")
|
||||
@State private var showCustomReminder = false
|
||||
@State private var customNumber: Int
|
||||
@State private var customUnitWeeks: Bool
|
||||
@State private var customHour: Int
|
||||
@State private var customMinute: Int
|
||||
@State private var customUnit: Int // 0 = minutes, 1 = hours, 2 = days
|
||||
@State private var localRepeat: String
|
||||
@State private var localRecurrenceEnd: Date?
|
||||
@State private var showRecurrenceEndPicker = false
|
||||
@State private var localIsAllDay: Bool
|
||||
@State private var editingEnd = false
|
||||
|
||||
@@ -1883,11 +2026,13 @@ struct TaskDatePickerSheet: View {
|
||||
isRecurring: Binding<Bool>, recurrenceLabel: Binding<String?>,
|
||||
endDate: Binding<Date?>, isAllDay: Binding<Bool>,
|
||||
reminderDate: Binding<Date?> = .constant(nil),
|
||||
constantReminder: Binding<Bool> = .constant(false)) {
|
||||
constantReminder: Binding<Bool> = .constant(false),
|
||||
recurrenceEnd: Binding<Date?> = .constant(nil)) {
|
||||
_selectedDate = selectedDate
|
||||
_hasTime = hasTime
|
||||
_isRecurring = isRecurring
|
||||
_recurrenceLabel = recurrenceLabel
|
||||
_recurrenceEnd = recurrenceEnd
|
||||
_endDate = endDate
|
||||
_isAllDay = isAllDay
|
||||
_reminderDate = reminderDate
|
||||
@@ -1898,34 +2043,29 @@ struct TaskDatePickerSheet: View {
|
||||
_localEndDate = State(initialValue: endDate.wrappedValue ?? start)
|
||||
_localTime = State(initialValue: hasTime.wrappedValue ? selectedDate.wrappedValue : nil)
|
||||
_localRepeat = State(initialValue: recurrenceLabel.wrappedValue ?? "None")
|
||||
_localRecurrenceEnd = State(initialValue: recurrenceEnd.wrappedValue)
|
||||
_localIsAllDay = State(initialValue: isAllDay.wrappedValue)
|
||||
_pickerMode = State(initialValue: endDate.wrappedValue != nil ? 1 : 0)
|
||||
_localConstant = State(initialValue: constantReminder.wrappedValue)
|
||||
|
||||
// Derive lead-time + time-of-day from any existing absolute reminder date.
|
||||
// Derive the lead-time (minutes before the event time) from any existing reminder.
|
||||
if let r = reminderDate.wrappedValue {
|
||||
let days = cal.dateComponents([.day],
|
||||
from: cal.startOfDay(for: r),
|
||||
to: cal.startOfDay(for: start)).day ?? 0
|
||||
let offset = max(0, days)
|
||||
_localReminderOffset = State(initialValue: offset)
|
||||
_localReminderTime = State(initialValue: r)
|
||||
if offset > 0 && offset % 7 == 0 {
|
||||
_customUnitWeeks = State(initialValue: true)
|
||||
_customNumber = State(initialValue: offset / 7)
|
||||
let base: Date = hasTime.wrappedValue
|
||||
? (selectedDate.wrappedValue ?? start)
|
||||
: (cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
|
||||
let mins = max(0, cal.dateComponents([.minute], from: r, to: base).minute ?? 0)
|
||||
_localReminderOffset = State(initialValue: mins)
|
||||
if mins > 0 && mins % 1440 == 0 {
|
||||
_customUnit = State(initialValue: 2); _customNumber = State(initialValue: mins / 1440)
|
||||
} else if mins > 0 && mins % 60 == 0 {
|
||||
_customUnit = State(initialValue: 1); _customNumber = State(initialValue: mins / 60)
|
||||
} else {
|
||||
_customUnitWeeks = State(initialValue: false)
|
||||
_customNumber = State(initialValue: offset)
|
||||
_customUnit = State(initialValue: 0); _customNumber = State(initialValue: max(1, mins))
|
||||
}
|
||||
_customHour = State(initialValue: cal.component(.hour, from: r))
|
||||
_customMinute = State(initialValue: cal.component(.minute, from: r))
|
||||
} else {
|
||||
_localReminderOffset = State(initialValue: nil)
|
||||
_localReminderTime = State(initialValue: cal.date(bySettingHour: 9, minute: 0, second: 0, of: start) ?? start)
|
||||
_customUnitWeeks = State(initialValue: false)
|
||||
_customNumber = State(initialValue: 0)
|
||||
_customHour = State(initialValue: 9)
|
||||
_customMinute = State(initialValue: 0)
|
||||
_customUnit = State(initialValue: 1) // default Custom draft: 1 hour
|
||||
_customNumber = State(initialValue: 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2048,6 +2188,32 @@ struct TaskDatePickerSheet: View {
|
||||
value: repeatDisplayValue, active: localRepeat != "None")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// ── Repeat until (only when a recurrence is set) ──
|
||||
if localRepeat != "None" {
|
||||
Divider().padding(.leading, 54)
|
||||
Button { withAnimation(KisaniSpring.micro) { showRecurrenceEndPicker.toggle() } } label: {
|
||||
pickerRow(icon: "calendar.badge.exclamationmark", label: "Repeat until",
|
||||
value: localRecurrenceEnd.map { repeatEndFmt.string(from: $0) } ?? "Forever",
|
||||
active: localRecurrenceEnd != nil)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if showRecurrenceEndPicker {
|
||||
DatePicker("", selection: Binding(
|
||||
get: { localRecurrenceEnd ?? localDate },
|
||||
set: { localRecurrenceEnd = $0 }
|
||||
), in: localDate..., displayedComponents: .date)
|
||||
.datePickerStyle(.graphical).labelsHidden()
|
||||
.padding(.horizontal, 8)
|
||||
if localRecurrenceEnd != nil {
|
||||
Button { withAnimation(KisaniSpring.micro) { localRecurrenceEnd = nil; showRecurrenceEndPicker = false } } label: {
|
||||
Text("Clear end date").font(AppFonts.sans(13)).foregroundColor(AppColors.accent)
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Color(uiColor: cs == .dark ? .secondarySystemBackground : .systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
@@ -2062,14 +2228,15 @@ struct TaskDatePickerSheet: View {
|
||||
let label: String
|
||||
}
|
||||
|
||||
// Offsets are MINUTES before the event time.
|
||||
private var reminderPresets: [ReminderPreset] {
|
||||
[
|
||||
ReminderPreset(offset: nil, label: "None"),
|
||||
ReminderPreset(offset: 0, label: "On the day"),
|
||||
ReminderPreset(offset: 1, label: "1 day early"),
|
||||
ReminderPreset(offset: 2, label: "2 days early"),
|
||||
ReminderPreset(offset: 3, label: "3 days early"),
|
||||
ReminderPreset(offset: 7, label: "1 week early"),
|
||||
ReminderPreset(offset: nil, label: "None"),
|
||||
ReminderPreset(offset: 0, label: "On time"),
|
||||
ReminderPreset(offset: 5, label: "5 minutes early"),
|
||||
ReminderPreset(offset: 30, label: "30 minutes early"),
|
||||
ReminderPreset(offset: 60, label: "1 hour early"),
|
||||
ReminderPreset(offset: 1440, label: "1 day early"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2080,12 +2247,7 @@ struct TaskDatePickerSheet: View {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.micro) {
|
||||
localReminderOffset = preset.offset
|
||||
if let off = preset.offset {
|
||||
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
|
||||
else { customUnitWeeks = false; customNumber = off }
|
||||
} else {
|
||||
localConstant = false
|
||||
}
|
||||
if preset.offset == nil { localConstant = false }
|
||||
showCustomReminder = false
|
||||
}
|
||||
} label: {
|
||||
@@ -2093,11 +2255,6 @@ struct TaskDatePickerSheet: View {
|
||||
Text(preset.label)
|
||||
.font(AppFonts.sans(15))
|
||||
.foregroundColor(preset.offset == nil ? AppColors.accent : .primary)
|
||||
if preset.offset != nil {
|
||||
Text("(\(timeFmt.string(from: localReminderTime)))")
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !showCustomReminder && localReminderOffset == preset.offset {
|
||||
Image(systemName: "checkmark")
|
||||
@@ -2146,38 +2303,34 @@ struct TaskDatePickerSheet: View {
|
||||
|
||||
// MARK: - Custom reminder wheel (Day / Week)
|
||||
|
||||
// Minutes-per-unit for the Custom wheel: minutes / hours / days.
|
||||
private let customUnitMinutes = [1, 60, 1440]
|
||||
private let customUnitNames = ["minute", "hour", "day"]
|
||||
|
||||
private func seedCustomDraft() {
|
||||
let cal = Calendar.current
|
||||
if let off = localReminderOffset {
|
||||
if off > 0 && off % 7 == 0 { customUnitWeeks = true; customNumber = off / 7 }
|
||||
else { customUnitWeeks = false; customNumber = off }
|
||||
}
|
||||
customHour = cal.component(.hour, from: localReminderTime)
|
||||
customMinute = cal.component(.minute, from: localReminderTime)
|
||||
guard let off = localReminderOffset, off > 0 else { return }
|
||||
if off % 1440 == 0 { customUnit = 2; customNumber = off / 1440 }
|
||||
else if off % 60 == 0 { customUnit = 1; customNumber = off / 60 }
|
||||
else { customUnit = 0; customNumber = off }
|
||||
}
|
||||
|
||||
private var customOffsetMinutes: Int { max(1, customNumber) * customUnitMinutes[customUnit] }
|
||||
|
||||
private func commitCustomReminder() {
|
||||
let cal = Calendar.current
|
||||
localReminderOffset = customUnitWeeks ? customNumber * 7 : customNumber
|
||||
localReminderTime = cal.date(bySettingHour: customHour, minute: customMinute,
|
||||
second: 0, of: localDate) ?? localReminderTime
|
||||
localReminderOffset = customOffsetMinutes
|
||||
}
|
||||
|
||||
// Label for a given count under the current Day/Week mode.
|
||||
// Label for a given count under the current unit.
|
||||
private func customLeadLabel(_ n: Int) -> String {
|
||||
if n == 0 { return "On the day" }
|
||||
let unit = customUnitWeeks ? "week" : "day"
|
||||
let unit = customUnitNames[customUnit]
|
||||
return "\(n) \(unit)\(n == 1 ? "" : "s") early"
|
||||
}
|
||||
|
||||
private var customMaxCount: Int { customUnitWeeks ? 8 : 60 }
|
||||
private var customMaxCount: Int { [59, 23, 30][customUnit] }
|
||||
|
||||
// Reminder date implied by the current *draft* wheel selection.
|
||||
private var draftReminderDate: Date? {
|
||||
let cal = Calendar.current
|
||||
let off = customUnitWeeks ? customNumber * 7 : customNumber
|
||||
let base = cal.date(byAdding: .day, value: -off, to: localDate) ?? localDate
|
||||
return cal.date(bySettingHour: customHour, minute: customMinute, second: 0, of: base)
|
||||
Calendar.current.date(byAdding: .minute, value: -customOffsetMinutes, to: reminderBaseDate())
|
||||
}
|
||||
|
||||
private var draftReminderInPast: Bool {
|
||||
@@ -2193,42 +2346,30 @@ struct TaskDatePickerSheet: View {
|
||||
.onTapGesture { withAnimation(KisaniSpring.snappy) { showCustomReminder = false } }
|
||||
|
||||
VStack(spacing: 18) {
|
||||
// Day / Week segmented toggle
|
||||
// Minutes / Hours / Days segmented toggle
|
||||
HStack(spacing: 2) {
|
||||
customModeButton("Day", weeks: false)
|
||||
customModeButton("Week", weeks: true)
|
||||
customModeButton("Minutes", unit: 0)
|
||||
customModeButton("Hours", unit: 1)
|
||||
customModeButton("Days", unit: 2)
|
||||
}
|
||||
.padding(3)
|
||||
.background(Color(uiColor: .tertiarySystemFill))
|
||||
.clipShape(Capsule())
|
||||
.padding(.top, 20)
|
||||
|
||||
// Three-column wheel: lead-time · hour · minute
|
||||
HStack(spacing: 0) {
|
||||
Picker("", selection: $customNumber) {
|
||||
ForEach(0...customMaxCount, id: \.self) { n in
|
||||
Text(customLeadLabel(n)).tag(n)
|
||||
}
|
||||
// Single lead-time wheel (relative to the event time)
|
||||
Picker("", selection: $customNumber) {
|
||||
ForEach(1...customMaxCount, id: \.self) { n in
|
||||
Text(customLeadLabel(n)).tag(n)
|
||||
}
|
||||
.pickerStyle(.wheel).frame(maxWidth: .infinity).clipped()
|
||||
|
||||
Picker("", selection: $customHour) {
|
||||
ForEach(0...23, id: \.self) { h in
|
||||
Text(String(format: "%02d", h)).tag(h)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel).frame(width: 64).clipped()
|
||||
|
||||
Picker("", selection: $customMinute) {
|
||||
ForEach(0...59, id: \.self) { m in
|
||||
Text(String(format: "%02d", m)).tag(m)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel).frame(width: 64).clipped()
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 180)
|
||||
.onChange(of: customUnitWeeks) { _ in
|
||||
.clipped()
|
||||
.onChange(of: customUnit) { _ in
|
||||
if customNumber > customMaxCount { customNumber = customMaxCount }
|
||||
if customNumber < 1 { customNumber = 1 }
|
||||
}
|
||||
|
||||
// Subtle (non-red) note — never alarming.
|
||||
@@ -2275,15 +2416,19 @@ struct TaskDatePickerSheet: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func customModeButton(_ label: String, weeks: Bool) -> some View {
|
||||
private func customModeButton(_ label: String, unit: Int) -> some View {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.micro) { customUnitWeeks = weeks }
|
||||
withAnimation(KisaniSpring.micro) {
|
||||
customUnit = unit
|
||||
if customNumber > customMaxCount { customNumber = customMaxCount }
|
||||
if customNumber < 1 { customNumber = 1 }
|
||||
}
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(AppFonts.sans(15, weight: customUnitWeeks == weeks ? .semibold : .regular))
|
||||
.foregroundColor(customUnitWeeks == weeks ? .primary : .secondary)
|
||||
.frame(width: 96, height: 36)
|
||||
.background(customUnitWeeks == weeks ? Color(uiColor: .systemGray4) : Color.clear)
|
||||
.font(AppFonts.sans(15, weight: customUnit == unit ? .semibold : .regular))
|
||||
.foregroundColor(customUnit == unit ? .primary : .secondary)
|
||||
.frame(maxWidth: .infinity).frame(height: 36)
|
||||
.background(customUnit == unit ? Color(uiColor: .systemGray4) : Color.clear)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -2294,22 +2439,26 @@ struct TaskDatePickerSheet: View {
|
||||
return computedReminderDate(offset: off) ?? Date() < Date()
|
||||
}
|
||||
|
||||
private func computedReminderDate(offset: Int) -> Date? {
|
||||
// The event datetime the reminder counts back from: the task's time, or 9 AM if undated.
|
||||
private func reminderBaseDate() -> Date {
|
||||
let cal = Calendar.current
|
||||
let base = cal.date(byAdding: .day, value: -offset, to: localDate) ?? localDate
|
||||
let tc = cal.dateComponents([.hour, .minute], from: localReminderTime)
|
||||
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: base)
|
||||
if let t = localTime {
|
||||
let tc = cal.dateComponents([.hour, .minute], from: t)
|
||||
return cal.date(bySettingHour: tc.hour ?? 9, minute: tc.minute ?? 0, second: 0, of: localDate) ?? localDate
|
||||
}
|
||||
return cal.date(bySettingHour: 9, minute: 0, second: 0, of: localDate) ?? localDate
|
||||
}
|
||||
|
||||
private func computedReminderDate(offset: Int) -> Date? {
|
||||
Calendar.current.date(byAdding: .minute, value: -offset, to: reminderBaseDate())
|
||||
}
|
||||
|
||||
private var reminderSummary: String {
|
||||
guard let off = localReminderOffset else { return "None" }
|
||||
let timeStr = timeFmt.string(from: localReminderTime)
|
||||
if off == 0 { return "On the day · \(timeStr)" }
|
||||
if off % 7 == 0 {
|
||||
let w = off / 7
|
||||
return "\(w) week\(w == 1 ? "" : "s") early · \(timeStr)"
|
||||
}
|
||||
return "\(off) day\(off == 1 ? "" : "s") early · \(timeStr)"
|
||||
if off == 0 { return "On time" }
|
||||
if off % 1440 == 0 { let d = off / 1440; return "\(d) day\(d == 1 ? "" : "s") early" }
|
||||
if off % 60 == 0 { let h = off / 60; return "\(h) hour\(h == 1 ? "" : "s") early" }
|
||||
return "\(off) minute\(off == 1 ? "" : "s") early"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -2453,6 +2602,10 @@ struct TaskDatePickerSheet: View {
|
||||
return "Duration: \(n) day\(n == 1 ? "" : "s")"
|
||||
}
|
||||
|
||||
private let repeatEndFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "MMM d, yyyy"; return f
|
||||
}()
|
||||
|
||||
private func commit() {
|
||||
if pickerMode == 0 {
|
||||
var final = Calendar.current.startOfDay(for: localDate)
|
||||
@@ -2469,6 +2622,7 @@ struct TaskDatePickerSheet: View {
|
||||
isAllDay = false
|
||||
isRecurring = localRepeat != "None"
|
||||
recurrenceLabel = localRepeat != "None" ? localRepeat : nil
|
||||
recurrenceEnd = localRepeat != "None" ? localRecurrenceEnd : nil
|
||||
// Reminder is a lead-time offset from the task date at the chosen time-of-day.
|
||||
if let off = localReminderOffset {
|
||||
reminderDate = computedReminderDate(offset: off)
|
||||
@@ -2560,7 +2714,7 @@ struct CalendarGridView: View {
|
||||
}
|
||||
|
||||
private var monthLabel: String {
|
||||
let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: displayMonth)
|
||||
let f = DateFormatter(); f.dateFormat = "MMMM yyyy"; return f.string(from: displayMonth)
|
||||
}
|
||||
|
||||
private func shift(_ d: Int) {
|
||||
|
||||
@@ -186,6 +186,7 @@ struct InViewTutorialCard: View {
|
||||
@Binding var step: Int
|
||||
let onAdvance: () -> Void
|
||||
let onSkip: () -> Void
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
private var current: PageTip { tips[min(step, tips.count - 1)] }
|
||||
private var isLast: Bool { step == tips.count - 1 }
|
||||
@@ -203,17 +204,17 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
Text(current.title)
|
||||
.font(AppFonts.sans(15, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Skip") { onSkip() }
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(.white.opacity(0.45))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
Text(current.body)
|
||||
.font(AppFonts.sans(13))
|
||||
.foregroundColor(.white.opacity(0.80))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineSpacing(2)
|
||||
|
||||
@@ -221,7 +222,7 @@ struct InViewTutorialCard: View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(0..<tips.count, id: \.self) { i in
|
||||
Capsule()
|
||||
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20))
|
||||
.fill(i == step ? AppColors.accent : AppColors.text3(cs).opacity(0.22))
|
||||
.frame(width: i == step ? 18 : 6, height: 6)
|
||||
.animation(KisaniSpring.micro, value: step)
|
||||
}
|
||||
@@ -243,8 +244,9 @@ struct InViewTutorialCard: View {
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
|
||||
.background(AppColors.surface(cs), in: RoundedRectangle(cornerRadius: 20))
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(AppColors.border(cs), lineWidth: 1))
|
||||
.shadow(color: Color.black.opacity(cs == .dark ? 0.28 : 0.08), radius: 18, x: 0, y: 10)
|
||||
.padding(.horizontal, 20)
|
||||
.id(step)
|
||||
.transition(.asymmetric(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import SwiftUI
|
||||
import AudioToolbox
|
||||
import Charts
|
||||
|
||||
// MARK: - Rest Timer Banner
|
||||
private struct RestTimerBanner: View {
|
||||
@@ -62,6 +63,7 @@ struct WorkoutView: View {
|
||||
@State private var showManage = false
|
||||
@State private var showAddSection = false
|
||||
@State private var showHistory = false
|
||||
@State private var showStats = false
|
||||
@State private var isReordering = false
|
||||
@State private var trainAnyway = false // override the rest-day state for today
|
||||
@ObservedObject private var tm = TutorialManager.shared
|
||||
@@ -101,8 +103,26 @@ struct WorkoutView: View {
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
Spacer()
|
||||
IButton(icon: "chart.bar.xaxis") { showStats = true }
|
||||
IButton(icon: "clock.arrow.circlepath") { showHistory = true }
|
||||
IButton(icon: "ellipsis") { showManage = true }
|
||||
Menu {
|
||||
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(true) } } label: {
|
||||
Label("Mark All Complete", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button { withAnimation(KisaniSpring.snappy) { vm.setAllSetsDone(false) } } label: {
|
||||
Label("Clear All Sets", systemImage: "circle")
|
||||
}
|
||||
Divider()
|
||||
Button { showManage = true } label: { Label("Manage Workouts", systemImage: "slider.horizontal.3") }
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.frame(width: 34, height: 34)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(AppColors.border(cs), lineWidth: 1))
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -158,6 +178,17 @@ struct WorkoutView: View {
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
.contextMenu {
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: true) }
|
||||
} label: {
|
||||
Label("Mark as Done", systemImage: "checkmark.circle")
|
||||
}
|
||||
Button {
|
||||
withAnimation { vm.setExerciseDone(sectionId: section.id, exerciseId: ex.id, done: false) }
|
||||
} label: {
|
||||
Label("Mark as Not Done", systemImage: "circle")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive) {
|
||||
withAnimation { vm.deleteExercise(sectionId: section.id, exerciseId: ex.id) }
|
||||
} label: {
|
||||
@@ -293,6 +324,11 @@ struct WorkoutView: View {
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showStats) {
|
||||
WorkoutStatsView().environmentObject(vm)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.sheet(isPresented: $showAddSection) {
|
||||
AddSectionSheet().environmentObject(vm)
|
||||
.presentationDetents([.height(240)])
|
||||
@@ -559,6 +595,130 @@ struct DotProgressCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout Stats
|
||||
struct WorkoutStatsView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var period: StatPeriod = .week
|
||||
@State private var metric: Metric = .volume
|
||||
|
||||
enum Metric: String, CaseIterable, Identifiable {
|
||||
case volume = "Volume", sets = "Sets", workouts = "Workouts"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
private var current: WorkoutStat { vm.stat(period, offset: 0) }
|
||||
private var previous: WorkoutStat { vm.stat(period, offset: -1) }
|
||||
private var buckets: [StatBucket] {
|
||||
switch period {
|
||||
case .day: return vm.statBuckets(.day, count: 14)
|
||||
case .week: return vm.statBuckets(.week, count: 8)
|
||||
case .month: return vm.statBuckets(.month, count: 6)
|
||||
}
|
||||
}
|
||||
|
||||
private func value(_ s: WorkoutStat) -> Double {
|
||||
switch metric { case .volume: return s.volume; case .sets: return Double(s.sets); case .workouts: return Double(s.workouts) }
|
||||
}
|
||||
private static let num: NumberFormatter = {
|
||||
let f = NumberFormatter(); f.numberStyle = .decimal; f.maximumFractionDigits = 0; return f
|
||||
}()
|
||||
private func volStr(_ v: Double) -> String { (Self.num.string(from: NSNumber(value: v)) ?? "0") + " kg" }
|
||||
|
||||
private func delta(_ cur: Double, _ prev: Double) -> (String, Bool)? {
|
||||
guard prev > 0 else { return cur > 0 ? ("New", true) : nil }
|
||||
let pct = (cur - prev) / prev * 100
|
||||
if abs(pct) < 1 { return ("±0%", true) }
|
||||
return (String(format: "%+.0f%%", pct), pct >= 0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Workout Stats").font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }.font(AppFonts.sans(13, weight: .semibold)).foregroundColor(AppColors.accent).buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, 20).padding(.top, 20).padding(.bottom, 12)
|
||||
|
||||
Picker("", selection: $period) {
|
||||
ForEach(StatPeriod.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented).padding(.horizontal, 20).padding(.bottom, 12)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if vm.history.isEmpty {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "chart.bar.xaxis").font(.system(size: 28)).foregroundColor(AppColors.text3(cs))
|
||||
Text("No data yet").font(AppFonts.sans(14, weight: .semibold)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Complete workouts and your stats appear here.")
|
||||
.font(AppFonts.sans(11)).foregroundColor(AppColors.text3(cs)).multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity).padding()
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 14) {
|
||||
// ── Summary cards (this period vs last) ──
|
||||
HStack(spacing: 10) {
|
||||
statCard("Workouts", "\(current.workouts)", delta(Double(current.workouts), Double(previous.workouts)))
|
||||
statCard("Sets", "\(current.sets)", delta(Double(current.sets), Double(previous.sets)))
|
||||
}
|
||||
statCard("Volume", volStr(current.volume), delta(current.volume, previous.volume), wide: true)
|
||||
|
||||
// ── Metric chooser ──
|
||||
Picker("", selection: $metric) {
|
||||
ForEach(Metric.allCases) { Text($0.rawValue).tag($0) }
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
// ── Trend chart ──
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(metric.rawValue.uppercased()) · LAST \(buckets.count) \(period.rawValue.uppercased())S")
|
||||
.font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
Chart(buckets) { b in
|
||||
BarMark(
|
||||
x: .value("Period", b.label),
|
||||
y: .value(metric.rawValue, value(b.stat))
|
||||
)
|
||||
.foregroundStyle(AppColors.accent.gradient)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.frame(height: 180)
|
||||
.chartYAxis { AxisMarks(position: .leading) }
|
||||
}
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
.padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 30)
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statCard(_ title: String, _ value: String, _ delta: (String, Bool)?, wide: Bool = false) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(title.uppercased()).font(AppFonts.mono(8.5, weight: .bold)).foregroundColor(AppColors.text3(cs))
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(value).font(AppFonts.mono(wide ? 24 : 20, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
if let d = delta {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: d.1 ? "arrow.up.right" : "arrow.down.right").font(.system(size: 9, weight: .bold))
|
||||
Text(d.0).font(AppFonts.mono(10, weight: .bold))
|
||||
}
|
||||
.foregroundColor(d.1 ? AppColors.green : AppColors.red)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text("vs last \(period.rawValue.lowercased())").font(AppFonts.sans(10)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(14).cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workout History
|
||||
struct WorkoutHistoryView: View {
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@@ -630,6 +790,88 @@ struct WorkoutHistoryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Compensation Sheet ("missed → make it up on a rest day")
|
||||
struct CompensationSheet: View {
|
||||
let missed: WorkoutViewModel.MissedDay
|
||||
@EnvironmentObject var vm: WorkoutViewModel
|
||||
@Environment(\.colorScheme) var cs
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
private let dayFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f
|
||||
}()
|
||||
|
||||
private var restDays: [Date] { vm.upcomingRestDays() }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "arrow.uturn.forward.circle")
|
||||
.font(.system(size: 30, weight: .light))
|
||||
.foregroundColor(AppColors.accent)
|
||||
Text("Missed: \(missed.programName)")
|
||||
.font(AppFonts.sans(17, weight: .bold)).foregroundColor(AppColors.text(cs))
|
||||
Text("Do you want to compensate for this workout on a rest day?")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.multilineTextAlignment(.center).padding(.horizontal, 24)
|
||||
}
|
||||
.padding(.top, 24).padding(.bottom, 16)
|
||||
|
||||
Divider().background(AppColors.border(cs))
|
||||
|
||||
if restDays.isEmpty {
|
||||
Text("No free rest days in the next two weeks.")
|
||||
.font(AppFonts.sans(12)).foregroundColor(AppColors.text3(cs))
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 30)
|
||||
} else {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(restDays, id: \.self) { day in
|
||||
Button {
|
||||
if let pid = missed.programId {
|
||||
vm.scheduleCompensation(programId: pid, on: day)
|
||||
}
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "moon.zzz")
|
||||
.font(.system(size: 13)).foregroundColor(AppColors.accent)
|
||||
.frame(width: 28)
|
||||
Text(dayFmt.string(from: day))
|
||||
.font(AppFonts.sans(14)).foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
Text("COMPENSATE")
|
||||
.font(AppFonts.mono(8.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||
.background(AppColors.accentSoft).clipShape(Capsule())
|
||||
}
|
||||
.padding(.horizontal, 18).padding(.vertical, 13)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
if day != restDays.last { Divider().background(AppColors.border(cs)).padding(.leading, 46) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button { dismiss() } label: {
|
||||
Text("No, keep as missed")
|
||||
.font(AppFonts.sans(14, weight: .semibold))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
.frame(maxWidth: .infinity).padding(13)
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 20).padding(.bottom, 20).padding(.top, 8)
|
||||
}
|
||||
.background(AppColors.background(cs).ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Day Card
|
||||
struct RestDayCard: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
|
||||
@@ -42,11 +42,13 @@ struct EventConfigIntent: WidgetConfigurationIntent {
|
||||
@Parameter(title: "Or event name", default: "My Event")
|
||||
var eventName: String
|
||||
|
||||
@Parameter(title: "Event date", default: Date.now)
|
||||
var targetDate: Date
|
||||
// Optional so the rows read "Choose" until the user actually picks a date —
|
||||
// a same-second default for both made the bar span zero and looked broken.
|
||||
@Parameter(title: "Event date")
|
||||
var targetDate: Date?
|
||||
|
||||
@Parameter(title: "Counting from", default: Date.now)
|
||||
var startDate: Date
|
||||
@Parameter(title: "Counting from")
|
||||
var startDate: Date?
|
||||
|
||||
// Show only the relevant fields depending on the toggle — like a clean,
|
||||
// single-purpose config screen instead of every field at once.
|
||||
@@ -74,6 +76,8 @@ struct EventEntity: AppEntity {
|
||||
let id: String // task UUID string
|
||||
let title: String
|
||||
let dueDate: Date?
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Event"
|
||||
static var defaultQuery = EventQuery()
|
||||
@@ -100,7 +104,8 @@ struct EventQuery: EntityQuery {
|
||||
loadWidgetTasks()
|
||||
.filter { !$0.isComplete && $0.dueDate != nil }
|
||||
.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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +156,18 @@ struct EventEntry: TimelineEntry {
|
||||
return max(0, d)
|
||||
}
|
||||
|
||||
/// Granular countdown for the bars widget: days → hr → min as it nears.
|
||||
var fineTimeLeft: String {
|
||||
if target <= date { return "Today" }
|
||||
let secs = target.timeIntervalSince(date)
|
||||
let days = Int(secs / 86400)
|
||||
let hrs = Int(secs.truncatingRemainder(dividingBy: 86400) / 3600)
|
||||
let mins = Int(secs.truncatingRemainder(dividingBy: 3600) / 60)
|
||||
if days >= 1 { return hrs > 0 ? "\(days)d, \(hrs) hr left" : "\(days) day\(days == 1 ? "" : "s") left" }
|
||||
if hrs >= 1 { return "\(hrs) hr, \(mins) min left" }
|
||||
return "\(mins) min left"
|
||||
}
|
||||
|
||||
/// Countdown from the current date. Tapping the label cycles the unit:
|
||||
/// days → weeks → time remaining.
|
||||
var timeLeftLabel: String {
|
||||
@@ -192,65 +209,168 @@ struct EventProvider: AppIntentTimelineProvider {
|
||||
}
|
||||
|
||||
func timeline(for configuration: EventConfigIntent, in context: Context) async -> Timeline<EventEntry> {
|
||||
let entry = entry(for: configuration)
|
||||
// Recompute at the next midnight so the grid advances one dot per day.
|
||||
let nextMidnight = Calendar.current.startOfDay(for: Date().addingTimeInterval(86400))
|
||||
return Timeline(entries: [entry], policy: .after(nextMidnight))
|
||||
let now = Date()
|
||||
let step: TimeInterval = 1800 // 30 minutes
|
||||
let count = 24 // ~12-hour pre-render window
|
||||
// Re-resolve the selection at EACH step's own time, so the moment the nearest
|
||||
// event expires the next entry rolls to the next-nearest automatically.
|
||||
let entries: [EventEntry] = (0..<count).map { i in
|
||||
entry(for: configuration, asOf: now.addingTimeInterval(Double(i) * step))
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
return Timeline(entries: entries, policy: .after(next))
|
||||
}
|
||||
|
||||
/// Upcoming events (incomplete tasks with a future due date), nearest first.
|
||||
private func upcomingEvents() -> [EventEntity] {
|
||||
/// Upcoming events (incomplete tasks with a due date still in the future *as of `now`*),
|
||||
/// nearest first. Passing `now` lets future timeline steps drop just-expired events.
|
||||
private func upcomingEvents(asOf now: Date) -> [EventEntity] {
|
||||
loadWidgetTasks()
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > Date() }
|
||||
.filter { !$0.isComplete && ($0.dueDate ?? .distantPast) > now }
|
||||
.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) }
|
||||
}
|
||||
|
||||
private func entry(for config: EventConfigIntent) -> EventEntry {
|
||||
var name: String
|
||||
var target: Date
|
||||
/// The tracked task ("Track Countdown"), but only while it's still live: not
|
||||
/// completed, and either recurring or with a deadline still ahead of `now`.
|
||||
/// Returns nil once it's done/expired so the widget advances to the next event.
|
||||
private func trackedTask(asOf now: Date) -> WidgetTask? {
|
||||
guard let trackedId = UserDefaults.kisani.string(forKey: "kisani.widget.trackedEvent"),
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == trackedId }),
|
||||
let due = task.dueDate,
|
||||
!task.isComplete,
|
||||
task.isRecurring || due > now
|
||||
else { return nil }
|
||||
return task
|
||||
}
|
||||
|
||||
if config.upcomingQueue {
|
||||
// Auto-track the Nth nearest upcoming event.
|
||||
let upcoming = upcomingEvents()
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
if idx < upcoming.count, let due = upcoming[idx].dueDate {
|
||||
name = upcoming[idx].title
|
||||
target = due
|
||||
} else {
|
||||
name = "No upcoming event"
|
||||
target = Date().addingTimeInterval(86400)
|
||||
}
|
||||
} else if let ev = config.event, let due = ev.dueDate {
|
||||
// A specifically picked event supplies its own name + date.
|
||||
name = ev.title
|
||||
target = due
|
||||
} else {
|
||||
// Fully custom event.
|
||||
name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
target = config.targetDate
|
||||
/// Due date the currently-shown event expires at, so the timeline can refresh
|
||||
/// the moment it's done. Covers both the tracked event and the auto-select queue.
|
||||
private func currentSelectionExpiry(for config: EventConfigIntent, asOf now: Date) -> Date? {
|
||||
if let task = trackedTask(asOf: now) {
|
||||
return task.isRecurring ? nil : task.dueDate
|
||||
}
|
||||
guard config.upcomingQueue 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
|
||||
/// (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
|
||||
/// from an explicit start, the tracking date, or first-seen, up to the due date.
|
||||
private func resolveSpan(due: Date, isRecurring: Bool, label: String?,
|
||||
anchorKey: String, explicitStart: Date?) -> (start: Date, target: Date) {
|
||||
if isRecurring, let label, let span = Self.recurrenceSpan(label: label, due: due) {
|
||||
return (span.prev, span.next)
|
||||
}
|
||||
if let s = explicitStart, s < due { return (s, due) }
|
||||
return (Self.storedAnchor(key: anchorKey, target: due), due)
|
||||
}
|
||||
|
||||
private func entry(for config: EventConfigIntent, asOf now: Date = Date()) -> EventEntry {
|
||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
|
||||
// An event tracked from the app ("Track Countdown") bypasses the widget
|
||||
// configuration entirely. Once it's completed or its deadline has passed
|
||||
// (and it isn't recurring), stop pinning it and fall through to the
|
||||
// next-nearest event below — so the widget always advances.
|
||||
if let task = trackedTask(asOf: now) {
|
||||
let trackedId = task.id.uuidString
|
||||
let since = UserDefaults.kisani.object(forKey: "kisani.widget.trackedSince") as? Date
|
||||
let s = resolveSpan(due: task.dueDate ?? now, isRecurring: task.isRecurring,
|
||||
label: task.recurrenceLabel, anchorKey: trackedId, explicitStart: since)
|
||||
return EventEntry(date: now, eventName: task.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
|
||||
// Counting anchor: custom mode honors the picker; auto mode counts from
|
||||
// "now" so the grid fills toward the event. Never start after the target.
|
||||
let anchor = config.upcomingQueue ? Date() : config.startDate
|
||||
let start = min(anchor, target)
|
||||
let mode = UserDefaults.kisani.integer(forKey: countdownUnitKey)
|
||||
return EventEntry(date: .now, eventName: name, start: start, target: target, unitMode: mode)
|
||||
// ── Configured (no tracked event) ──
|
||||
if config.upcomingQueue {
|
||||
let upcoming = upcomingEvents(asOf: now)
|
||||
let idx = max(0, config.autoSelect.rawValue - 1)
|
||||
guard idx < upcoming.count, let due = upcoming[idx].dueDate else {
|
||||
return EventEntry(date: now, eventName: "No upcoming event",
|
||||
start: now, target: now.addingTimeInterval(86400), unitMode: mode)
|
||||
}
|
||||
let ev = upcoming[idx]
|
||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||
anchorKey: ev.id, explicitStart: nil)
|
||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
if let ev = config.event, let due = ev.dueDate {
|
||||
let s = resolveSpan(due: due, isRecurring: ev.isRecurring, label: ev.recurrenceLabel,
|
||||
anchorKey: ev.id, explicitStart: config.startDate)
|
||||
return EventEntry(date: now, eventName: ev.title, start: s.start, target: s.target, unitMode: mode)
|
||||
}
|
||||
// Fully custom event (no task behind it).
|
||||
let name = config.eventName.isEmpty ? "My Event" : config.eventName
|
||||
let target = config.targetDate
|
||||
?? Calendar.current.startOfDay(for: now).addingTimeInterval(2 * 86400)
|
||||
let anchorKey = "custom.\(name).\(Int(target.timeIntervalSince1970))"
|
||||
let start = (config.startDate.map { $0 < target ? $0 : nil } ?? nil)
|
||||
?? Self.storedAnchor(key: anchorKey, target: target)
|
||||
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
|
||||
/// is stable across refreshes (and resets naturally when the event changes).
|
||||
private static func storedAnchor(key: String, target: Date) -> Date {
|
||||
let ud = UserDefaults.kisani
|
||||
let k = "kisani.widget.anchor.\(key)"
|
||||
if let saved = ud.object(forKey: k) as? Date, saved < target { return saved }
|
||||
let now = Date()
|
||||
ud.set(now, forKey: k)
|
||||
return now
|
||||
}
|
||||
|
||||
/// Mode B window for a recurring event: the occurrence span containing "now"
|
||||
/// (previous occurrence → next occurrence), derived from the rule label.
|
||||
private static func recurrenceSpan(label: String, due: Date) -> (prev: Date, next: Date)? {
|
||||
let cal = Calendar.current
|
||||
let now = Date()
|
||||
let comp: Calendar.Component
|
||||
let step: Int
|
||||
switch label {
|
||||
case "Daily", "Every Weekday": comp = .day; step = 1
|
||||
case "Weekly": comp = .day; step = 7
|
||||
case "Monthly": comp = .month; step = 1
|
||||
case "Yearly": comp = .year; step = 1
|
||||
default: return nil
|
||||
}
|
||||
var next = due
|
||||
var guardRail = 0
|
||||
if next > now {
|
||||
// Walk back until the previous occurrence is in the past.
|
||||
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
||||
prev > now, guardRail < 4000 { next = prev; guardRail += 1 }
|
||||
} else {
|
||||
while next <= now, let n = cal.date(byAdding: comp, value: step, to: next),
|
||||
guardRail < 4000 { next = n; guardRail += 1 }
|
||||
}
|
||||
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
||||
return (prev, next)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Widget
|
||||
// MARK: - Event Countdown (Bars)
|
||||
|
||||
struct EventCountdownWidget: Widget {
|
||||
let kind = "KisaniEventCountdown"
|
||||
struct EventBarsWidget: Widget {
|
||||
let kind = "KisaniEventBars"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: EventConfigIntent.self, provider: EventProvider()) { entry in
|
||||
EventCountdownView(entry: entry)
|
||||
EventBarsView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("Event Countdown")
|
||||
.description("Track the days until any upcoming event with a dot grid.")
|
||||
.supportedFamilies([.systemMedium, .systemSmall])
|
||||
.configurationDisplayName("Event Countdown (Bars)")
|
||||
.description("Count down to an event with a bar meter.")
|
||||
.supportedFamilies([.systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Kisani Cal Widgets</string>
|
||||
<string>Wenza Widgets</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
|
||||
@@ -36,29 +36,232 @@ struct KisaniProvider: TimelineProvider {
|
||||
|
||||
// MARK: - Day Progress Widget ("Day done %")
|
||||
|
||||
struct DayProgressWidget: Widget {
|
||||
let kind = "KisaniDayProgress"
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
|
||||
ProgressDotView(period: .day, accent: Color(red: 0.48, green: 0.87, blue: 0.80))
|
||||
enum ProgressDotMode: String, AppEnum {
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
case customEvent
|
||||
|
||||
static var typeDisplayRepresentation: TypeDisplayRepresentation = "Progress Mode"
|
||||
static var caseDisplayRepresentations: [ProgressDotMode: DisplayRepresentation] = [
|
||||
.day: "This Day",
|
||||
.week: "This Week",
|
||||
.month: "This Month",
|
||||
.customEvent: "Custom Event",
|
||||
]
|
||||
}
|
||||
|
||||
struct ProgressDotConfigIntent: WidgetConfigurationIntent {
|
||||
static var title: LocalizedStringResource = "Progress Dots"
|
||||
static var description = IntentDescription("Track day, week, month, or a Wenza event.")
|
||||
|
||||
@Parameter(title: "Mode", default: .month)
|
||||
var mode: ProgressDotMode
|
||||
|
||||
@Parameter(title: "Event")
|
||||
var event: EventEntity?
|
||||
|
||||
static var parameterSummary: some ParameterSummary {
|
||||
When(\ProgressDotConfigIntent.$mode, .equalTo, ProgressDotMode.customEvent) {
|
||||
Summary("Show \(\.$mode) for \(\.$event)")
|
||||
} otherwise: {
|
||||
Summary("Show \(\.$mode)")
|
||||
}
|
||||
.configurationDisplayName("Day Progress")
|
||||
.description("How much of today has elapsed, as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tasks Done Today Widget ("Tasks complete %")
|
||||
struct ProgressDotEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let title: String
|
||||
let progress: Double
|
||||
let totalDots: Int
|
||||
}
|
||||
|
||||
struct TasksCompleteWidget: Widget {
|
||||
let kind = "KisaniTasksComplete"
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: KisaniProvider()) { _ in
|
||||
TasksCompleteView()
|
||||
struct ProgressDotProvider: AppIntentTimelineProvider {
|
||||
func placeholder(in context: Context) -> ProgressDotEntry {
|
||||
ProgressDotEntry(date: .now, title: Self.dayTitle(for: .now), progress: 0.06, totalDots: 100)
|
||||
}
|
||||
|
||||
func snapshot(for configuration: ProgressDotConfigIntent, in context: Context) async -> ProgressDotEntry {
|
||||
entry(for: configuration, at: .now)
|
||||
}
|
||||
|
||||
func timeline(for configuration: ProgressDotConfigIntent, in context: Context) async -> Timeline<ProgressDotEntry> {
|
||||
let interval: TimeInterval = 5 * 60
|
||||
let entries = (0..<72).map { step in
|
||||
entry(for: configuration, at: Date().addingTimeInterval(Double(step) * interval))
|
||||
}
|
||||
.configurationDisplayName("Tasks Done Today")
|
||||
.description("Percentage of today's tasks you've completed.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium])
|
||||
return Timeline(entries: entries, policy: .after(Date().addingTimeInterval(Double(entries.count) * interval)))
|
||||
}
|
||||
|
||||
private func entry(for configuration: ProgressDotConfigIntent, at date: Date) -> ProgressDotEntry {
|
||||
switch configuration.mode {
|
||||
case .day:
|
||||
let start = Calendar.current.startOfDay(for: date)
|
||||
let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.dayTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .week:
|
||||
let start = Self.startOfISOWeek(containing: date)
|
||||
let end = Calendar(identifier: .iso8601).date(byAdding: .weekOfYear, value: 1, to: start) ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.weekTitle(for: start),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .month:
|
||||
let interval = Calendar.current.dateInterval(of: .month, for: date)
|
||||
let start = interval?.start ?? date
|
||||
let end = interval?.end ?? date
|
||||
return ProgressDotEntry(date: date, title: Self.monthTitle(for: date),
|
||||
progress: Self.progress(now: date, start: start, end: end),
|
||||
totalDots: 100)
|
||||
case .customEvent:
|
||||
guard let event = configuration.event,
|
||||
let task = loadWidgetTasks().first(where: { $0.id.uuidString == event.id }),
|
||||
let due = task.dueDate
|
||||
else {
|
||||
return ProgressDotEntry(date: date, title: "Select Event", progress: 0, totalDots: 100)
|
||||
}
|
||||
|
||||
let start = Self.eventStartDate(for: task, due: due, now: date)
|
||||
let computedProgress = Self.progress(now: date, start: start, end: due)
|
||||
let storedProgress = task.progress.map(Self.clamp) ?? 0
|
||||
return ProgressDotEntry(date: date, title: task.title,
|
||||
progress: max(storedProgress, computedProgress),
|
||||
totalDots: 100)
|
||||
}
|
||||
}
|
||||
|
||||
private static func progress(now: Date, start: Date, end: Date) -> Double {
|
||||
let total = end.timeIntervalSince(start)
|
||||
guard total > 0 else { return end <= now ? 1 : 0 }
|
||||
return clamp(now.timeIntervalSince(start) / total)
|
||||
}
|
||||
|
||||
private static func clamp(_ value: Double) -> Double {
|
||||
min(1, max(0, value))
|
||||
}
|
||||
|
||||
private static func eventStartDate(for task: WidgetTask, due: Date, now: Date) -> Date {
|
||||
if let span = recurrenceSpan(for: task, due: due, now: now) {
|
||||
return span.prev
|
||||
}
|
||||
|
||||
let start = [task.createdAt, task.reminderDate]
|
||||
.compactMap { $0 }
|
||||
.filter { $0 < due }
|
||||
.min()
|
||||
|
||||
let stored = storedAnchor(key: task.id.uuidString, target: due, now: now)
|
||||
return bestStartDate(explicit: start, stored: stored, target: due, now: now)
|
||||
}
|
||||
|
||||
private static func storedAnchor(key: String, target: Date, now: Date) -> Date {
|
||||
let userDefaults = UserDefaults.kisani
|
||||
let key = "kisani.widget.progressDots.anchor.\(key)"
|
||||
if let saved = userDefaults.object(forKey: key) as? Date, saved < target {
|
||||
return saved
|
||||
}
|
||||
userDefaults.set(now, forKey: key)
|
||||
return now
|
||||
}
|
||||
|
||||
private static func bestStartDate(explicit: Date?, stored: Date, target: Date, now: Date) -> Date {
|
||||
let candidate = explicit ?? stored
|
||||
guard candidate < target else { return inferredLegacyStart(target: target, now: now) }
|
||||
|
||||
let remaining = target.timeIntervalSince(now)
|
||||
let elapsed = now.timeIntervalSince(candidate)
|
||||
let total = target.timeIntervalSince(candidate)
|
||||
let progress = total > 0 ? elapsed / total : 0
|
||||
let looksLikeUpgradeAnchor = remaining > 14 * 86400
|
||||
&& elapsed >= 0
|
||||
&& elapsed < 3 * 86400
|
||||
&& progress < (1.0 / 100.0)
|
||||
|
||||
return looksLikeUpgradeAnchor ? inferredLegacyStart(target: target, now: now) : candidate
|
||||
}
|
||||
|
||||
private static func inferredLegacyStart(target: Date, now: Date) -> Date {
|
||||
let remainingDays = max(1, Int(ceil(target.timeIntervalSince(now) / 86400)))
|
||||
let totalDays = min(365, max(30, remainingDays * 2))
|
||||
return Calendar.current.date(byAdding: .day, value: -totalDays, to: target) ?? now
|
||||
}
|
||||
|
||||
private static func recurrenceSpan(for task: WidgetTask, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||
let category = task.category.lowercased()
|
||||
if category == "birthday" || category == "annual" {
|
||||
return recurrenceSpan(label: "Yearly", due: due, now: now)
|
||||
}
|
||||
guard task.isRecurring, let label = task.recurrenceLabel else { return nil }
|
||||
return recurrenceSpan(label: label, due: due, now: now)
|
||||
}
|
||||
|
||||
private static func recurrenceSpan(label: String, due: Date, now: Date) -> (prev: Date, next: Date)? {
|
||||
let cal = Calendar.current
|
||||
let comp: Calendar.Component
|
||||
let step: Int
|
||||
switch label {
|
||||
case "Daily", "Every Weekday": comp = .day; step = 1
|
||||
case "Weekly": comp = .day; step = 7
|
||||
case "Monthly": comp = .month; step = 1
|
||||
case "Yearly": comp = .year; step = 1
|
||||
default: return nil
|
||||
}
|
||||
|
||||
var next = due
|
||||
var guardRail = 0
|
||||
if next > now {
|
||||
while let prev = cal.date(byAdding: comp, value: -step, to: next),
|
||||
prev > now, guardRail < 4000 {
|
||||
next = prev
|
||||
guardRail += 1
|
||||
}
|
||||
} else {
|
||||
while next <= now, let candidate = cal.date(byAdding: comp, value: step, to: next),
|
||||
guardRail < 4000 {
|
||||
next = candidate
|
||||
guardRail += 1
|
||||
}
|
||||
}
|
||||
|
||||
guard let prev = cal.date(byAdding: comp, value: -step, to: next) else { return nil }
|
||||
return (prev, next)
|
||||
}
|
||||
|
||||
private static func startOfISOWeek(containing date: Date) -> Date {
|
||||
let cal = Calendar(identifier: .iso8601)
|
||||
return cal.dateInterval(of: .weekOfYear, for: date)?.start ?? cal.startOfDay(for: date)
|
||||
}
|
||||
|
||||
private static func dayTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private static func weekTitle(for start: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
return "Week of \(formatter.string(from: start))"
|
||||
}
|
||||
|
||||
private static func monthTitle(for date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMMM yyyy"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
struct DayProgressWidget: Widget {
|
||||
let kind = "KisaniDayProgress"
|
||||
var body: some WidgetConfiguration {
|
||||
AppIntentConfiguration(kind: kind, intent: ProgressDotConfigIntent.self, provider: ProgressDotProvider()) { entry in
|
||||
ProgressDotView(entry: entry, accent: WidgetTheme.accent)
|
||||
}
|
||||
.configurationDisplayName("Progress Dots")
|
||||
.description("Track day, week, month, or a selected event as a dot grid.")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,10 +308,9 @@ struct LockScreenCircularWidget: Widget {
|
||||
@main
|
||||
struct KisaniWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
EventCountdownWidget()
|
||||
EventBarsWidget()
|
||||
MyTasksWidget()
|
||||
DayProgressWidget()
|
||||
TasksCompleteWidget()
|
||||
LockScreenWidget()
|
||||
LockScreenCircularWidget()
|
||||
TaskLiveActivity()
|
||||
|
||||
@@ -95,16 +95,16 @@ struct MyTasksView: 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))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
Text("\(entry.openCount)")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.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))
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -112,7 +112,7 @@ struct MyTasksView: View {
|
||||
if entry.tasks.isEmpty {
|
||||
Spacer()
|
||||
Text("All clear for today 🎉")
|
||||
.font(.system(size: 13))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
Spacer()
|
||||
@@ -127,7 +127,7 @@ struct MyTasksView: View {
|
||||
.buttonStyle(.plain)
|
||||
|
||||
Text(task.title)
|
||||
.font(.system(size: 14))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(task.isComplete ? Color(white: 0.45) : .white)
|
||||
.strikethrough(task.isComplete, color: Color(white: 0.45))
|
||||
.lineLimit(1)
|
||||
@@ -136,13 +136,13 @@ struct MyTasksView: View {
|
||||
}
|
||||
if entry.tasks.count > maxRows {
|
||||
Text("+\(entry.tasks.count - maxRows) more")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(Color(white: 0.4))
|
||||
.padding(.leading, 28)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.containerBackground(.black, for: .widget)
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import SwiftUI
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
struct TaskLiveActivity: Widget {
|
||||
private let accent = Color(red: 0.93, green: 0.42, blue: 0.20) // brand orange
|
||||
private let accent = WidgetTheme.accent // brand orange
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: TaskActivityAttributes.self) { context in
|
||||
@@ -13,29 +13,38 @@ struct TaskLiveActivity: Widget {
|
||||
Image(systemName: context.state.isComplete ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 22))
|
||||
.foregroundColor(context.state.isComplete ? .green : accent)
|
||||
.frame(width: 24)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
.strikethrough(context.state.isComplete)
|
||||
if let due = context.state.dueDate {
|
||||
Text(due, style: context.state.isComplete ? .date : .relative)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
.layoutPriority(1)
|
||||
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 70)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
.frame(width: 92, alignment: .trailing)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.leading, 14)
|
||||
.padding(.trailing, 10)
|
||||
.padding(.vertical, 12)
|
||||
.activityBackgroundTint(Color.black.opacity(0.55))
|
||||
.activityBackgroundTint(WidgetTheme.background.opacity(0.9))
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
|
||||
} dynamicIsland: { context in
|
||||
@@ -47,14 +56,17 @@ struct TaskLiveActivity: Widget {
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 64)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
.frame(width: 86, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.center) {
|
||||
Text(context.attributes.title)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
}
|
||||
} compactLeading: {
|
||||
@@ -63,8 +75,11 @@ struct TaskLiveActivity: Widget {
|
||||
if let due = context.state.dueDate, !context.state.isComplete {
|
||||
Text(due, style: .timer)
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.monospacedDigit()
|
||||
.foregroundColor(accent)
|
||||
.frame(maxWidth: 44)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.frame(width: 48, alignment: .trailing)
|
||||
}
|
||||
} minimal: {
|
||||
Image(systemName: "checklist").foregroundColor(accent)
|
||||
|
||||
@@ -10,6 +10,11 @@ struct WidgetTask: Codable, Identifiable {
|
||||
let isComplete: Bool
|
||||
let quadrant: String // raw value of Quadrant enum
|
||||
let category: String
|
||||
var isRecurring: Bool = false
|
||||
var recurrenceLabel: String? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
|
||||
// Days remaining until due date
|
||||
var daysLeft: Int? {
|
||||
@@ -60,7 +65,10 @@ func loadWidgetTasks() -> [WidgetTask] {
|
||||
else { return [] }
|
||||
return raw.map {
|
||||
WidgetTask(id: $0.id, title: $0.title, dueDate: $0.dueDate,
|
||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category)
|
||||
isComplete: $0.isComplete, quadrant: $0.quadrant, category: $0.category,
|
||||
isRecurring: $0.isRecurring ?? false, recurrenceLabel: $0.recurrenceLabel,
|
||||
createdAt: $0.createdAt, reminderDate: $0.reminderDate,
|
||||
progress: $0.countdownProgress ?? $0.progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,8 +80,15 @@ private struct RawTask: Codable {
|
||||
var isComplete: Bool = false
|
||||
var quadrant: String = "Urgent + Important"
|
||||
var category: String = "reminder"
|
||||
var isRecurring: Bool? = nil
|
||||
var recurrenceLabel: String? = nil
|
||||
var createdAt: Date? = nil
|
||||
var reminderDate: Date? = nil
|
||||
var progress: Double? = nil
|
||||
var countdownProgress: Double? = nil
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, dueDate, isComplete, quadrant, category
|
||||
case id, title, dueDate, isComplete, quadrant, category, isRecurring, recurrenceLabel
|
||||
case createdAt, reminderDate, progress, countdownProgress
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,21 +153,6 @@ private func CloudSyncManager_widgetPush(_ data: Data, _ key: String) {
|
||||
kv.synchronize()
|
||||
}
|
||||
|
||||
// Today's task completion: (done, total) for tasks due today.
|
||||
func todayTaskCompletion() -> (done: Int, total: Int) {
|
||||
let cal = Calendar.current
|
||||
let start = cal.startOfDay(for: Date())
|
||||
let end = cal.date(byAdding: .day, value: 1, to: start)!
|
||||
let tasks = loadWidgetTasks()
|
||||
func dueToday(_ t: WidgetTask) -> Bool {
|
||||
guard let due = t.dueDate else { return false }
|
||||
return due >= start && due < end
|
||||
}
|
||||
let active = tasks.filter { !$0.isComplete && dueToday($0) }
|
||||
let completed = tasks.filter { $0.isComplete && dueToday($0) }
|
||||
return (completed.count, active.count + completed.count)
|
||||
}
|
||||
|
||||
// Workout streak count
|
||||
func loadWorkoutStreak() -> Int {
|
||||
let uid = UserDefaults.kisani.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
|
||||
@@ -1,145 +1,136 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// MARK: - Dot-grid progress (day / week / month / year)
|
||||
// MARK: - Shared widget theme (slate background + brand orange)
|
||||
|
||||
enum TimePeriod { case day, week, month, year }
|
||||
enum WidgetTheme {
|
||||
// Exactly the brand palette: app accent RGB(232,98,42) on the logo's
|
||||
// dark RGB(26,29,34) — widgets read as siblings of the app icon.
|
||||
static let background = Color(red: 26/255, green: 29/255, blue: 34/255)
|
||||
static let accent = Color(red: 232/255, green: 98/255, blue: 42/255)
|
||||
static let text = Color.white
|
||||
static let textDim = Color.white.opacity(0.55)
|
||||
static let textFaint = Color.white.opacity(0.4)
|
||||
}
|
||||
|
||||
struct ProgressDotView: View {
|
||||
let period: TimePeriod
|
||||
let accent: Color
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var label: String {
|
||||
switch period {
|
||||
case .day: let f = DateFormatter(); f.dateFormat = "EEEE d"; return f.string(from: Date())
|
||||
case .week: return "This week"
|
||||
case .month: let f = DateFormatter(); f.dateFormat = "MMMM"; return f.string(from: Date())
|
||||
case .year: let f = DateFormatter(); f.dateFormat = "yyyy"; return f.string(from: Date())
|
||||
private extension String {
|
||||
func widgetTitleCased() -> String {
|
||||
split(separator: " ").map { rawWord in
|
||||
let word = String(rawWord)
|
||||
guard let first = word.first else { return word }
|
||||
if word == word.uppercased(), word.count <= 5 { return word }
|
||||
return first.uppercased() + word.dropFirst().lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
let cal = Calendar.current; let now = Date()
|
||||
switch period {
|
||||
case .day:
|
||||
let sod = cal.startOfDay(for: now)
|
||||
return (now.timeIntervalSince(sod)) / 86400
|
||||
case .week:
|
||||
let comps = cal.dateComponents([.weekday], from: now)
|
||||
let wd = (comps.weekday ?? 1) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(wd) * 86400 + secIntoDay) / (7 * 86400)
|
||||
case .month:
|
||||
let range = cal.range(of: .day, in: .month, for: now)!
|
||||
let day = cal.component(.day, from: now) - 1
|
||||
let secIntoDay = now.timeIntervalSince(cal.startOfDay(for: now))
|
||||
return (Double(day) * 86400 + secIntoDay) / (Double(range.count) * 86400)
|
||||
case .year:
|
||||
let start = cal.date(from: cal.dateComponents([.year], from: now))!
|
||||
let end = cal.date(byAdding: .year, value: 1, to: start)!
|
||||
return now.timeIntervalSince(start) / end.timeIntervalSince(start)
|
||||
}
|
||||
}
|
||||
|
||||
private var subLabel: String {
|
||||
let pct = Int(progress * 100)
|
||||
return "\(pct)%"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let isMedium = family == .systemMedium
|
||||
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(label)
|
||||
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text(subLabel)
|
||||
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
DotGrid(total: 100, filled: Int(progress * 100), color: accent)
|
||||
}
|
||||
.padding(.horizontal, isMedium ? 4 : 0)
|
||||
.containerBackground(.black, for: .widget)
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tasks completed today
|
||||
// MARK: - Vertical-bar progress (comb / equalizer style)
|
||||
|
||||
struct TasksCompleteView: View {
|
||||
@Environment(\.widgetFamily) private var family
|
||||
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
|
||||
|
||||
private var stats: (done: Int, total: Int) { todayTaskCompletion() }
|
||||
private var percent: Int {
|
||||
let s = stats
|
||||
guard s.total > 0 else { return 0 }
|
||||
return Int((Double(s.done) / Double(s.total) * 100).rounded())
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let s = stats
|
||||
let isMedium = family == .systemMedium
|
||||
VStack(alignment: .leading, spacing: isMedium ? 10 : 6) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("Today's Tasks")
|
||||
.font(.system(size: isMedium ? 22 : 14, weight: .bold))
|
||||
.foregroundColor(.white)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(.system(size: isMedium ? 22 : 13, weight: .bold))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
}
|
||||
if s.total == 0 {
|
||||
Spacer(minLength: 0)
|
||||
Text("No tasks due today")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color(white: 0.5))
|
||||
Spacer(minLength: 0)
|
||||
} else {
|
||||
Text("\(s.done) of \(s.total) done")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(teal)
|
||||
Spacer(minLength: 2)
|
||||
DotGrid(total: s.total, filled: s.done, color: teal)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, isMedium ? 4 : 0)
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Circular dot grid (event countdown motif)
|
||||
|
||||
struct DotGrid: View {
|
||||
let total: Int
|
||||
let filled: Int
|
||||
struct BarGrid: View {
|
||||
let progress: Double // 0…1
|
||||
let color: Color
|
||||
var count: Int = 64
|
||||
var gap: CGFloat = 3
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let l = layout(for: total, in: geo.size)
|
||||
VStack(spacing: gap) {
|
||||
ForEach(0..<l.rows, id: \.self) { row in
|
||||
HStack(spacing: gap) {
|
||||
ForEach(0..<l.cols, id: \.self) { col in
|
||||
let idx = row * l.cols + col
|
||||
if idx < total {
|
||||
dot(filled: idx < filled, size: l.size)
|
||||
} else {
|
||||
Color.clear.frame(width: l.size, height: l.size)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
let totalGap = gap * CGFloat(count - 1)
|
||||
let barW = max(1.5, (geo.size.width - totalGap) / CGFloat(count))
|
||||
let filled = Int((progress * Double(count)).rounded())
|
||||
HStack(spacing: gap) {
|
||||
ForEach(0..<count, id: \.self) { i in
|
||||
RoundedRectangle(cornerRadius: barW / 2)
|
||||
.fill(i < filled ? color : color.opacity(0.22))
|
||||
.frame(width: barW)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(height: geo.size.height, alignment: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dot-grid progress
|
||||
|
||||
struct ProgressDotView: View {
|
||||
let entry: ProgressDotEntry
|
||||
let accent: Color
|
||||
@Environment(\.widgetFamily) private var family
|
||||
|
||||
private var headerFont: Font {
|
||||
switch family {
|
||||
case .systemLarge: return .system(.title3, design: .monospaced)
|
||||
case .accessoryRectangular: return .system(.caption, design: .monospaced)
|
||||
default: return .system(.body, design: .monospaced)
|
||||
}
|
||||
}
|
||||
|
||||
private var spacing: CGFloat {
|
||||
family == .accessoryRectangular ? 4 : 10
|
||||
}
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
switch family {
|
||||
case .systemLarge: return 8
|
||||
case .accessoryRectangular: return 0
|
||||
default: return 4
|
||||
}
|
||||
}
|
||||
|
||||
private var isAccessory: Bool { family == .accessoryRectangular }
|
||||
|
||||
// 100 tiny dots are illegible in a lock-screen pill — show a coarse grid there.
|
||||
private var dotCount: Int { isAccessory ? 28 : entry.totalDots }
|
||||
|
||||
var body: some View {
|
||||
let percent = Int((entry.progress * 100).rounded(.down))
|
||||
VStack(alignment: .leading, spacing: spacing) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(entry.title)
|
||||
.font(headerFont)
|
||||
.foregroundColor(isAccessory ? .primary : .white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
Text("\(percent)%")
|
||||
.font(headerFont)
|
||||
.foregroundColor(isAccessory ? .primary.opacity(0.7) : Color(white: 0.5))
|
||||
.lineLimit(1)
|
||||
}
|
||||
DotGridView(progress: entry.progress, totalDots: dotCount,
|
||||
color: isAccessory ? .primary : accent)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.containerBackground(for: .widget) {
|
||||
if isAccessory { AccessoryWidgetBackground() } else { WidgetTheme.background }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reusable circular dot grid
|
||||
|
||||
struct DotGridView: View {
|
||||
let progress: Double
|
||||
var totalDots: Int = 100
|
||||
let color: Color
|
||||
var gap: CGFloat = 3
|
||||
|
||||
private var filledDots: Int {
|
||||
Int((min(1, max(0, progress)) * Double(totalDots)).rounded(.down))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
let layout = layout(for: totalDots, in: geo.size)
|
||||
let columns = Array(repeating: GridItem(.fixed(layout.size), spacing: gap, alignment: .center),
|
||||
count: layout.cols)
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: gap) {
|
||||
ForEach(0..<totalDots, id: \.self) { index in
|
||||
dot(filled: index < filledDots, size: layout.size)
|
||||
}
|
||||
}
|
||||
.frame(width: layout.width, height: layout.height, alignment: .topLeading)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,88 +145,56 @@ struct DotGrid: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pick the column count that yields the largest uniform circle that still fits
|
||||
/// every dot inside the available area — an even grid that fills the widget.
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0))
|
||||
private func layout(for count: Int, in size: CGSize) -> (cols: Int, rows: Int, size: CGFloat, width: CGFloat, height: CGFloat) {
|
||||
guard count > 0, size.width > 1, size.height > 1 else { return (1, 1, 6, 6, 6) }
|
||||
var best = (cols: 1, rows: count, size: CGFloat(0), width: CGFloat(0), height: CGFloat(0))
|
||||
for cols in 1...count {
|
||||
let rows = Int(ceil(Double(count) / Double(cols)))
|
||||
let w = (size.width - gap * CGFloat(cols - 1)) / CGFloat(cols)
|
||||
let h = (size.height - gap * CGFloat(rows - 1)) / CGFloat(rows)
|
||||
let s = min(w, h)
|
||||
if s > best.size { best = (cols, rows, s) }
|
||||
let gridWidth = CGFloat(cols) * s + gap * CGFloat(cols - 1)
|
||||
let gridHeight = CGFloat(rows) * s + gap * CGFloat(rows - 1)
|
||||
if s > best.size && gridWidth <= size.width && gridHeight <= size.height {
|
||||
best = (cols, rows, s, gridWidth, gridHeight)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event Countdown Widget (small + medium)
|
||||
// MARK: - Event Countdown — Bars style (medium)
|
||||
|
||||
struct EventCountdownView: View {
|
||||
struct EventBarsView: View {
|
||||
let entry: EventEntry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private let teal = Color(red: 0.48, green: 0.87, blue: 0.80)
|
||||
|
||||
private var percent: Int { Int((entry.progress * 100).rounded()) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if family == .systemSmall {
|
||||
small
|
||||
} else {
|
||||
medium
|
||||
}
|
||||
}
|
||||
.containerBackground(.black, for: .widget)
|
||||
}
|
||||
|
||||
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 {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 20, weight: .bold))
|
||||
.foregroundColor(teal)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||
Text(entry.eventName.widgetTitleCased())
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.accent)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 4)
|
||||
VStack(alignment: .trailing, spacing: 1) {
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
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))
|
||||
.minimumScaleFactor(0.78)
|
||||
.layoutPriority(1)
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.fineTimeLeft)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.foregroundColor(WidgetTheme.textDim)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.9)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.layoutPriority(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var small: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(LocalizedStringKey(entry.eventName))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundColor(teal)
|
||||
.lineLimit(2)
|
||||
Button(intent: CycleCountdownUnitIntent()) {
|
||||
Text(entry.timeLeftLabel)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color(white: 0.62))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
Spacer(minLength: 2)
|
||||
DotGrid(total: entry.totalDays, filled: filled, color: teal)
|
||||
Spacer(minLength: 8)
|
||||
BarGrid(progress: entry.progress, color: WidgetTheme.accent)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 4).padding(.vertical, 2)
|
||||
.containerBackground(WidgetTheme.background, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,10 +207,10 @@ struct LockScreenRectView: View {
|
||||
HStack(spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(task.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.lineLimit(1)
|
||||
Text(task.timeLeftLabel)
|
||||
.font(.system(size: 11))
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
@@ -259,7 +218,7 @@ struct LockScreenRectView: View {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.frame(width: 22, height: 22)
|
||||
.tint(.white)
|
||||
.tint(WidgetTheme.accent)
|
||||
} else if task.isComplete {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
@@ -279,14 +238,14 @@ struct LockScreenCircularView: View {
|
||||
if !task.isComplete, let days = task.daysLeft, days <= 365 {
|
||||
ProgressView(value: Double(365 - days), total: 365)
|
||||
.progressViewStyle(.circular)
|
||||
.tint(.white)
|
||||
.tint(WidgetTheme.accent)
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
if let days = task.daysLeft {
|
||||
Text("\(days)").font(.system(size: 14, weight: .bold))
|
||||
Text("days").font(.system(size: 7))
|
||||
Text("\(days)").font(.system(.caption, design: .monospaced))
|
||||
Text("days").font(.system(.caption2, design: .monospaced))
|
||||
} else {
|
||||
Image(systemName: "checkmark").font(.system(size: 14, weight: .bold))
|
||||
Image(systemName: "checkmark").font(.system(size: 14))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
WenzaWatch/Info.plist
Normal file
28
WenzaWatch/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Wenza</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>WKApplication</key>
|
||||
<true/>
|
||||
<key>WKCompanionAppBundleIdentifier</key>
|
||||
<string>com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
8
WenzaWatch/WenzaWatch.entitlements
Normal file
8
WenzaWatch/WenzaWatch.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)com.kutesir.KisaniCal</string>
|
||||
</dict>
|
||||
</plist>
|
||||
221
WenzaWatch/WenzaWatchApp.swift
Normal file
221
WenzaWatch/WenzaWatchApp.swift
Normal file
@@ -0,0 +1,221 @@
|
||||
import SwiftUI
|
||||
import WatchKit
|
||||
|
||||
// MARK: - Shared iCloud task store
|
||||
//
|
||||
// The Watch is a separate device and can't read the iPhone's App Group, so it
|
||||
// reads/writes the SAME tasks via the iCloud key-value store (NSUbiquitousKeyValueStore)
|
||||
// that CloudSyncManager already mirrors on the phone. Writes here propagate back to
|
||||
// the phone, which applies external KV changes into its App Group.
|
||||
|
||||
private func tasksKey() -> String {
|
||||
let uid = NSUbiquitousKeyValueStore.default.string(forKey: "kisani.activeUserId") ?? "shared"
|
||||
return "kisani.tasks.v2.\(uid)"
|
||||
}
|
||||
|
||||
private let occFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f
|
||||
}()
|
||||
|
||||
struct WatchTask: Identifiable {
|
||||
let id: UUID
|
||||
var title: String
|
||||
var dueDate: Date?
|
||||
var isComplete: Bool
|
||||
var isRecurring: Bool
|
||||
var recurrenceLabel: String?
|
||||
var completedOccurrences: [String]
|
||||
}
|
||||
|
||||
final class WatchStore: ObservableObject {
|
||||
@Published var tasks: [WatchTask] = []
|
||||
private let kv = NSUbiquitousKeyValueStore.default
|
||||
private let cal = Calendar.current
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(externalChange),
|
||||
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kv)
|
||||
kv.synchronize()
|
||||
load()
|
||||
}
|
||||
|
||||
@objc private func externalChange() {
|
||||
kv.synchronize()
|
||||
DispatchQueue.main.async { [weak self] in self?.load() }
|
||||
}
|
||||
|
||||
func load() {
|
||||
guard let data = kv.data(forKey: tasksKey()),
|
||||
let arr = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] else {
|
||||
tasks = []; return
|
||||
}
|
||||
tasks = arr.compactMap { d in
|
||||
guard let idStr = d["id"] as? String, let id = UUID(uuidString: idStr),
|
||||
let title = d["title"] as? String else { return nil }
|
||||
let due = (d["dueDate"] as? Double).map { Date(timeIntervalSinceReferenceDate: $0) }
|
||||
return WatchTask(id: id, title: title, dueDate: due,
|
||||
isComplete: d["isComplete"] as? Bool ?? false,
|
||||
isRecurring: d["isRecurring"] as? Bool ?? false,
|
||||
recurrenceLabel: d["recurrenceLabel"] as? String,
|
||||
completedOccurrences: d["completedOccurrences"] as? [String] ?? [])
|
||||
}
|
||||
}
|
||||
|
||||
// Today's actionable tasks: dated today/overdue, plus recurring occurrences due today.
|
||||
var todayTasks: [WatchTask] {
|
||||
let end = cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: Date()))!
|
||||
return tasks.filter { t in
|
||||
if t.isRecurring { return occursToday(t) && !completedToday(t) }
|
||||
guard !t.isComplete, let due = t.dueDate else { return false }
|
||||
return due < end
|
||||
}
|
||||
.sorted { ($0.dueDate ?? .distantFuture) < ($1.dueDate ?? .distantFuture) }
|
||||
}
|
||||
|
||||
private func completedToday(_ t: WatchTask) -> Bool {
|
||||
t.completedOccurrences.contains(occFmt.string(from: Date()))
|
||||
}
|
||||
|
||||
private func occursToday(_ t: WatchTask) -> Bool {
|
||||
guard let due = t.dueDate else { return false }
|
||||
let now = Date()
|
||||
if due > cal.date(byAdding: .day, value: 1, to: cal.startOfDay(for: now))! { return false }
|
||||
switch t.recurrenceLabel {
|
||||
case "Daily": return true
|
||||
case "Every Weekday": let wd = cal.component(.weekday, from: now); return wd >= 2 && wd <= 6
|
||||
case "Weekly": return cal.component(.weekday, from: due) == cal.component(.weekday, from: now)
|
||||
case "Monthly": return cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
case "Yearly": return cal.component(.month, from: due) == cal.component(.month, from: now)
|
||||
&& cal.component(.day, from: due) == cal.component(.day, from: now)
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(_ task: WatchTask) {
|
||||
mutate { arr in
|
||||
for i in arr.indices where (arr[i]["id"] as? String) == task.id.uuidString {
|
||||
if task.isRecurring {
|
||||
var occ = arr[i]["completedOccurrences"] as? [String] ?? []
|
||||
let k = occFmt.string(from: Date())
|
||||
if let idx = occ.firstIndex(of: k) { occ.remove(at: idx) } else { occ.append(k) }
|
||||
arr[i]["completedOccurrences"] = occ
|
||||
} else {
|
||||
let done = (arr[i]["isComplete"] as? Bool) ?? false
|
||||
arr[i]["isComplete"] = !done
|
||||
if !done { arr[i]["completedAt"] = Date().timeIntervalSinceReferenceDate }
|
||||
else { arr[i].removeValue(forKey: "completedAt") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ title: String) {
|
||||
let t = title.trimmingCharacters(in: .whitespaces)
|
||||
guard !t.isEmpty else { return }
|
||||
mutate { arr in
|
||||
// All non-optional TaskItem keys must be present (synthesized Decodable
|
||||
// throws on missing keys); raw values mirror the iOS model.
|
||||
arr.append([
|
||||
"id": UUID().uuidString,
|
||||
"title": t,
|
||||
"dueDate": self.cal.startOfDay(for: Date()).timeIntervalSinceReferenceDate,
|
||||
"hasTime": false,
|
||||
"isComplete": false,
|
||||
"quadrant": "Urgent + Important",
|
||||
"category": "Reminder",
|
||||
"taskColor": "orange",
|
||||
"isRecurring": false,
|
||||
"isPinned": false,
|
||||
"isAllDay": false,
|
||||
"priority": "None",
|
||||
"constantReminder": false,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func mutate(_ change: (inout [[String: Any]]) -> Void) {
|
||||
let key = tasksKey()
|
||||
var arr: [[String: Any]] = []
|
||||
if let data = kv.data(forKey: key),
|
||||
let parsed = (try? JSONSerialization.jsonObject(with: data)) as? [[String: Any]] { arr = parsed }
|
||||
change(&arr)
|
||||
if let out = try? JSONSerialization.data(withJSONObject: arr) {
|
||||
kv.set(out, forKey: key)
|
||||
kv.synchronize()
|
||||
}
|
||||
load()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
@main
|
||||
struct WenzaWatchApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup { TaskListView() }
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskListView: View {
|
||||
@StateObject private var store = WatchStore()
|
||||
@State private var showAdd = false
|
||||
|
||||
private let timeFmt: DateFormatter = {
|
||||
let f = DateFormatter(); f.dateFormat = "h:mm a"; return f
|
||||
}()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
Button { showAdd = true } label: {
|
||||
Label("Add Task", systemImage: "plus.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
|
||||
if store.todayTasks.isEmpty {
|
||||
Text("All caught up")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.footnote)
|
||||
} else {
|
||||
ForEach(store.todayTasks) { task in
|
||||
Button { withAnimation { WKInterfaceDevice.current().play(.success); store.toggle(task) } } label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "circle")
|
||||
.foregroundStyle(.orange)
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(task.title).lineLimit(2)
|
||||
if let due = task.dueDate, !task.isRecurring {
|
||||
Text(timeFmt.string(from: due))
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Today")
|
||||
.sheet(isPresented: $showAdd) {
|
||||
AddTaskView { store.add($0); showAdd = false }
|
||||
}
|
||||
.onAppear { store.reload() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddTaskView: View {
|
||||
let onAdd: (String) -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var text = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
TextField("New task", text: $text) // Watch dictation / Scribble
|
||||
Button("Add") { onAdd(text) }
|
||||
.disabled(text.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
45
project.yml
45
project.yml
@@ -9,8 +9,8 @@ options:
|
||||
settings:
|
||||
base:
|
||||
DEVELOPMENT_TEAM: K8BLMMR883
|
||||
MARKETING_VERSION: "1.0"
|
||||
CURRENT_PROJECT_VERSION: "2"
|
||||
MARKETING_VERSION: "2.0"
|
||||
CURRENT_PROJECT_VERSION: "9"
|
||||
|
||||
schemes:
|
||||
KisaniCal:
|
||||
@@ -38,6 +38,11 @@ targets:
|
||||
dependencies:
|
||||
- target: KisaniCalWidgets
|
||||
embed: true
|
||||
# WenzaWatch is built/embedded only when the watchOS platform is installed.
|
||||
# Re-enable shipping it on the Watch by uncommenting the embed below (requires
|
||||
# Xcode > Settings > Components > watchOS):
|
||||
# - target: WenzaWatch
|
||||
# embed: true
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
@@ -45,7 +50,7 @@ targets:
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
|
||||
INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Kisani Cal"
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Wenza"
|
||||
INFOPLIST_KEY_UIStatusBarStyle: UIStatusBarStyleDefault
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"
|
||||
@@ -55,10 +60,10 @@ targets:
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGN_ENTITLEMENTS: KisaniCal/KisaniCal.entitlements
|
||||
ENABLE_PREVIEWS: YES
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Kisani Cal shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription: "Kisani Cal reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Kisani Cal saves workouts you complete to the Health app."
|
||||
INFOPLIST_KEY_NSCalendarsUsageDescription: "Wenza shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription: "Wenza shows your calendar events alongside tasks."
|
||||
INFOPLIST_KEY_NSHealthShareUsageDescription: "Wenza reads your steps, energy, heart rate, and workouts to show health stats on your dashboard."
|
||||
INFOPLIST_KEY_NSHealthUpdateUsageDescription: "Wenza saves workouts you complete to the Health app."
|
||||
|
||||
KisaniCalWidgets:
|
||||
type: app-extension
|
||||
@@ -71,7 +76,7 @@ targets:
|
||||
info:
|
||||
path: KisaniCalWidgets/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "Kisani Cal Widgets"
|
||||
CFBundleDisplayName: "Wenza Widgets"
|
||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||
NSExtension:
|
||||
@@ -85,3 +90,27 @@ targets:
|
||||
LD_RUNPATH_SEARCH_PATHS: "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"
|
||||
APPLICATION_EXTENSION_API_ONLY: YES
|
||||
CODE_SIGN_ENTITLEMENTS: KisaniCalWidgets/KisaniCalWidgets.entitlements
|
||||
|
||||
WenzaWatch:
|
||||
type: application
|
||||
platform: watchOS
|
||||
deploymentTarget: "10.0"
|
||||
sources:
|
||||
- path: WenzaWatch
|
||||
info:
|
||||
path: WenzaWatch/Info.plist
|
||||
properties:
|
||||
CFBundleDisplayName: "Wenza"
|
||||
CFBundleShortVersionString: "$(MARKETING_VERSION)"
|
||||
CFBundleVersion: "$(CURRENT_PROJECT_VERSION)"
|
||||
WKApplication: true
|
||||
WKCompanionAppBundleIdentifier: com.kutesir.KisaniCal
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: 5.9
|
||||
GENERATE_INFOPLIST_FILE: NO
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.kutesir.KisaniCal.watchkitapp
|
||||
TARGETED_DEVICE_FAMILY: "4"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGN_ENTITLEMENTS: WenzaWatch/WenzaWatch.entitlements
|
||||
INFOPLIST_KEY_CFBundleDisplayName: "Wenza"
|
||||
|
||||
98
support-site/index.html
Normal file
98
support-site/index.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Wenza — Support</title>
|
||||
<meta name="description" content="Support for Wenza — a personal tasks, workout, and calendar companion for iPhone." />
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1a1d22;
|
||||
--surface: #21252b;
|
||||
--border: #2e333b;
|
||||
--text: #f2f3f5;
|
||||
--muted: #9aa3ad;
|
||||
--accent: #ff7a1a;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { -webkit-text-size-adjust: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 7vh 24px 64px;
|
||||
}
|
||||
.wrap { width: 100%; max-width: 560px; }
|
||||
.mark {
|
||||
width: 44px; height: 44px; border-radius: 12px;
|
||||
background: var(--accent);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 700; font-size: 22px; color: var(--bg);
|
||||
margin-bottom: 28px;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
h1 { font-size: 30px; font-weight: 700; letter-spacing: -0.6px; margin-bottom: 10px; }
|
||||
.lede { color: var(--muted); font-size: 17px; margin-bottom: 44px; }
|
||||
h2 {
|
||||
font-size: 13px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; color: var(--muted); margin-bottom: 14px;
|
||||
}
|
||||
section { margin-bottom: 40px; }
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 20px 22px;
|
||||
}
|
||||
.card a { color: var(--accent); text-decoration: none; font-weight: 600; }
|
||||
.card a:hover { text-decoration: underline; }
|
||||
.faq { border-bottom: 1px solid var(--border); padding: 16px 0; }
|
||||
.faq:first-child { padding-top: 0; }
|
||||
.faq:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.faq h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
|
||||
.faq p { color: var(--muted); font-size: 15px; }
|
||||
footer { color: var(--muted); font-size: 13px; margin-top: 8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="mark">W</div>
|
||||
<h1>Wenza Support</h1>
|
||||
<p class="lede">A quiet, personal companion for your tasks, your training, and your calendar.</p>
|
||||
|
||||
<section>
|
||||
<h2>Contact</h2>
|
||||
<div class="card">
|
||||
Questions, bugs, or feedback? Email
|
||||
<a href="mailto:support@example.com">support@example.com</a>.
|
||||
I read every message and usually reply within a couple of days.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Common questions</h2>
|
||||
<div class="card">
|
||||
<div class="faq">
|
||||
<h3>Does Wenza collect my data?</h3>
|
||||
<p>No. Your tasks, workouts, and health data stay on your device and in your own iCloud. Nothing is sent to a server or any third party.</p>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<h3>Will my data sync across devices?</h3>
|
||||
<p>Settings and data sync through your private iCloud account, so they survive reinstalls and new devices signed in to the same Apple ID.</p>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<h3>Why does Wenza ask for Health and Calendar access?</h3>
|
||||
<p>Health access shows your steps, energy, heart rate, and workouts on your dashboard. Calendar access lets your events appear alongside your tasks. Both are optional.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>© 2026 Wenza. All rights reserved.</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user