Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-01-27 19:17:37 -06:00
parent 6c195c3083
commit 9c5fe23488
32 changed files with 1006 additions and 146 deletions

View File

@ -8,6 +8,10 @@
/* Begin PBXBuildFile section */
EAC04AEE2F26BD5B007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04AED2F26BD5B007F87EA /* Bedrock */; };
EAC04D322F298D9B007F87EA /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAC04D312F298D9B007F87EA /* WidgetKit.framework */; };
EAC04D342F298D9B007F87EA /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EAC04D332F298D9B007F87EA /* SwiftUI.framework */; };
EAC04D412F298D9C007F87EA /* AndromidaWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
EAC04E2C2F2998B2007F87EA /* Bedrock in Frameworks */ = {isa = PBXBuildFile; productRef = EAC04E2B2F2998B2007F87EA /* Bedrock */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -25,17 +29,67 @@
remoteGlobalIDString = EAC04A972F26BAE8007F87EA;
remoteInfo = Andromida;
};
EAC04D3F2F298D9C007F87EA /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = EAC04A902F26BAE8007F87EA /* Project object */;
proxyType = 1;
remoteGlobalIDString = EAC04D2E2F298D9B007F87EA;
remoteInfo = AndromidaWidgetExtension;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
EAC04D462F298D9C007F87EA /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
EAC04D412F298D9C007F87EA /* AndromidaWidgetExtension.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
EAC04A982F26BAE8007F87EA /* Andromida.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Andromida.app; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AndromidaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04D312F298D9B007F87EA /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
EAC04D332F298D9B007F87EA /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
EAC04D4D2F298DD9007F87EA /* AndromidaWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = AndromidaWidgetExtension.entitlements; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
EAC04D422F298D9C007F87EA /* Exceptions for "AndromidaWidget" folder in "AndromidaWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
};
EAC04D512F298EAE007F87EA /* Exceptions for "Andromida" folder in "AndromidaWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
App/Models/ArcHabit.swift,
App/Models/Ritual.swift,
App/Models/RitualArc.swift,
Assets.xcassets,
Shared/Configuration/AppIdentifiers.swift,
Shared/Services/RitualAnalytics.swift,
);
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
EAC04A9A2F26BAE8007F87EA /* Andromida */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EAC04D512F298EAE007F87EA /* Exceptions for "Andromida" folder in "AndromidaWidgetExtension" target */,
);
path = Andromida;
sourceTree = "<group>";
};
@ -49,6 +103,14 @@
path = AndromidaUITests;
sourceTree = "<group>";
};
EAC04D352F298D9B007F87EA /* AndromidaWidget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
EAC04D422F298D9C007F87EA /* Exceptions for "AndromidaWidget" folder in "AndromidaWidgetExtension" target */,
);
path = AndromidaWidget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -74,15 +136,28 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAC04D2C2F298D9B007F87EA /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EAC04E2C2F2998B2007F87EA /* Bedrock in Frameworks */,
EAC04D342F298D9B007F87EA /* SwiftUI.framework in Frameworks */,
EAC04D322F298D9B007F87EA /* WidgetKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
EAC04A8F2F26BAE8007F87EA = {
isa = PBXGroup;
children = (
EAC04D4D2F298DD9007F87EA /* AndromidaWidgetExtension.entitlements */,
EAC04A9A2F26BAE8007F87EA /* Andromida */,
EAC04AA82F26BAE9007F87EA /* AndromidaTests */,
EAC04AB22F26BAE9007F87EA /* AndromidaUITests */,
EAC04D352F298D9B007F87EA /* AndromidaWidget */,
EAC04D302F298D9B007F87EA /* Frameworks */,
EAC04A992F26BAE8007F87EA /* Products */,
);
sourceTree = "<group>";
@ -93,10 +168,20 @@
EAC04A982F26BAE8007F87EA /* Andromida.app */,
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */,
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */,
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */,
);
name = Products;
sourceTree = "<group>";
};
EAC04D302F298D9B007F87EA /* Frameworks */ = {
isa = PBXGroup;
children = (
EAC04D312F298D9B007F87EA /* WidgetKit.framework */,
EAC04D332F298D9B007F87EA /* SwiftUI.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -107,10 +192,12 @@
EAC04A942F26BAE8007F87EA /* Sources */,
EAC04A952F26BAE8007F87EA /* Frameworks */,
EAC04A962F26BAE8007F87EA /* Resources */,
EAC04D462F298D9C007F87EA /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
EAC04D402F298D9C007F87EA /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
EAC04A9A2F26BAE8007F87EA /* Andromida */,
@ -169,6 +256,29 @@
productReference = EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */ = {
isa = PBXNativeTarget;
buildConfigurationList = EAC04D432F298D9C007F87EA /* Build configuration list for PBXNativeTarget "AndromidaWidgetExtension" */;
buildPhases = (
EAC04D2B2F298D9B007F87EA /* Sources */,
EAC04D2C2F298D9B007F87EA /* Frameworks */,
EAC04D2D2F298D9B007F87EA /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
EAC04D352F298D9B007F87EA /* AndromidaWidget */,
);
name = AndromidaWidgetExtension;
packageProductDependencies = (
EAC04E2B2F2998B2007F87EA /* Bedrock */,
);
productName = AndromidaWidgetExtension;
productReference = EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -190,6 +300,9 @@
CreatedOnToolsVersion = 26.2;
TestTargetID = EAC04A972F26BAE8007F87EA;
};
EAC04D2E2F298D9B007F87EA = {
CreatedOnToolsVersion = 26.2;
};
};
};
buildConfigurationList = EAC04A932F26BAE8007F87EA /* Build configuration list for PBXProject "Andromida" */;
@ -212,6 +325,7 @@
EAC04A972F26BAE8007F87EA /* Andromida */,
EAC04AA42F26BAE9007F87EA /* AndromidaTests */,
EAC04AAE2F26BAE9007F87EA /* AndromidaUITests */,
EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */,
);
};
/* End PBXProject section */
@ -238,6 +352,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAC04D2D2F298D9B007F87EA /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -262,6 +383,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
EAC04D2B2F298D9B007F87EA /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@ -275,11 +403,18 @@
target = EAC04A972F26BAE8007F87EA /* Andromida */;
targetProxy = EAC04AB02F26BAE9007F87EA /* PBXContainerItemProxy */;
};
EAC04D402F298D9C007F87EA /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = EAC04D2E2F298D9B007F87EA /* AndromidaWidgetExtension */;
targetProxy = EAC04D3F2F298D9C007F87EA /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
EAC04AB72F26BAE9007F87EA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Debug.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@ -344,6 +479,8 @@
};
EAC04AB82F26BAE9007F87EA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Release.xcconfig;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
@ -401,6 +538,8 @@
};
EAC04ABA2F26BAE9007F87EA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Debug.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -435,6 +574,8 @@
};
EAC04ABB2F26BAE9007F87EA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Release.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
@ -469,6 +610,8 @@
};
EAC04ABD2F26BAE9007F87EA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Debug.xcconfig;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@ -476,7 +619,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.AndromidaTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -491,6 +634,8 @@
};
EAC04ABE2F26BAE9007F87EA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Release.xcconfig;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@ -498,7 +643,7 @@
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.AndromidaTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -513,12 +658,14 @@
};
EAC04AC02F26BAE9007F87EA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Debug.xcconfig;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.AndromidaUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -533,12 +680,14 @@
};
EAC04AC12F26BAE9007F87EA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Release.xcconfig;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.AndromidaUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -551,6 +700,72 @@
};
name = Release;
};
EAC04D442F298D9C007F87EA /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Debug.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = AndromidaWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AndromidaWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AndromidaWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.Andromida.AndromidaWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
EAC04D452F298D9C007F87EA /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReferenceAnchor = EAC04A9A2F26BAE8007F87EA /* Andromida */;
baseConfigurationReferenceRelativePath = Shared/Configuration/Release.xcconfig;
buildSettings = {
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
CODE_SIGN_ENTITLEMENTS = AndromidaWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = AndromidaWidget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = AndromidaWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.1;
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.Andromida.AndromidaWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -590,6 +805,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
EAC04D432F298D9C007F87EA /* Build configuration list for PBXNativeTarget "AndromidaWidgetExtension" */ = {
isa = XCConfigurationList;
buildConfigurations = (
EAC04D442F298D9C007F87EA /* Debug */,
EAC04D452F298D9C007F87EA /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
@ -604,6 +828,11 @@
isa = XCSwiftPackageProductDependency;
productName = Bedrock;
};
EAC04E2B2F2998B2007F87EA /* Bedrock */ = {
isa = XCSwiftPackageProductDependency;
package = EAC04AEC2F26BD5B007F87EA /* XCLocalSwiftPackageReference "../Bedrock" */;
productName = Bedrock;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = EAC04A902F26BAE8007F87EA /* Project object */;

View File

@ -9,6 +9,11 @@
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>AndromidaWidgetExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
</dict>
</dict>
</plist>

View File

@ -3,12 +3,16 @@
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.mbrucedogs.Andromida</string>
</array>
<array>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>

View File

@ -25,11 +25,15 @@ struct AndromidaApp: App {
// Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
// Use App Group for shared container between app and widget
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false,
cloudKitDatabase: .private("iCloud.com.mbrucedogs.Andromida")
url: FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite"),
cloudKitDatabase: .private(AppIdentifiers.cloudKitContainerIdentifier)
)
let container: ModelContainer
do {
container = try ModelContainer(for: schema, configurations: [configuration])

View File

@ -1131,6 +1131,12 @@
"comment" : "Habit title for a ritual preset focused on self-care, emphasizing gentle stretching as a habit.",
"isCommentAutoGenerated" : true
},
"Get a gentle nudge each morning to start your day right" : {
"comment" : "Description for notification permission screen when user selected morning rituals."
},
"Get a gentle reminder when it's time for your rituals" : {
"comment" : "Default description for notification permission screen."
},
"Get more done" : {
"comment" : "Subtitle for the \"Focus\" onboarding goal.",
"isCommentAutoGenerated" : true
@ -1143,12 +1149,6 @@
"comment" : "The text for the \"Get Started\" button in the welcome screen.",
"isCommentAutoGenerated" : true
},
"Get a gentle nudge each morning to start your day right" : {
"comment" : "Description for notification permission screen when user selected morning rituals."
},
"Get a gentle reminder when it's time for your rituals" : {
"comment" : "Default description for notification permission screen."
},
"Give your mind a break from screens." : {
"comment" : "Notes for a ritual preset focused on giving the mind a break from screens.",
"isCommentAutoGenerated" : true
@ -1367,6 +1367,18 @@
"comment" : "Habit title for a mindfulness ritual where the user writes in a journal for 5 minutes.",
"isCommentAutoGenerated" : true
},
"Keep going" : {
"comment" : "Cancel button label to continue onboarding.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Keep going"
}
}
}
},
"Keep room cool" : {
"comment" : "Habit title for keeping the bedroom cool at night.",
"isCommentAutoGenerated" : true
@ -1456,29 +1468,6 @@
},
"Mindfulness" : {
},
"Momentum at a glance" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Momentum at a glance"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Impulso de un vistazo"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Élan en un coup dœil"
}
}
}
},
"More" : {
"comment" : "The text for a button that expands or collapses a list.",
@ -2169,22 +2158,6 @@
},
"Shows rituals for the current time of day. Check in here daily." : {
},
"Skip for now" : {
"comment" : "A button label that allows users to skip creating a new ritual for now.",
"isCommentAutoGenerated" : true
},
"Skip setup?" : {
"comment" : "Alert title asking if the user wants to skip onboarding setup.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Skip setup?"
}
}
}
},
"Skip" : {
"comment" : "Button label to skip onboarding.",
@ -2198,26 +2171,18 @@
}
}
},
"Keep going" : {
"comment" : "Cancel button label to continue onboarding.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Keep going"
}
}
}
"Skip for now" : {
"comment" : "A button label that allows users to skip creating a new ritual for now.",
"isCommentAutoGenerated" : true
},
"You can complete setup later in Settings." : {
"comment" : "Alert message explaining that setup can be completed later in Settings.",
"Skip setup?" : {
"comment" : "Alert title asking if the user wants to skip onboarding setup.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "You can complete setup later in Settings."
"value" : "Skip setup?"
}
}
}
@ -2282,9 +2247,6 @@
"comment" : "Description of a trend direction when there is no significant change.",
"isCommentAutoGenerated" : true
},
"Stay on track" : {
"comment" : "Headline on the notification permission screen during onboarding."
},
"Start" : {
"comment" : "A button that starts a new arc for a ritual.",
"isCommentAutoGenerated" : true
@ -2324,6 +2286,9 @@
"comment" : "Subtitle for the \"Morning\" option in the \"Time of Day\" section of the onboarding screen.",
"isCommentAutoGenerated" : true
},
"Stay on track" : {
"comment" : "Headline on the notification permission screen during onboarding."
},
"Streak" : {
"comment" : "Title of a section in the Insight Cards view, related to streaks of consecutive perfect days.",
"isCommentAutoGenerated" : true
@ -2358,29 +2323,6 @@
"comment" : "Description of a ritual preset that serves as a weekly reset to help users start their week on a positive note.",
"isCommentAutoGenerated" : true
},
"Switch tabs to explore rituals and insights" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Switch tabs to explore rituals and insights"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Cambia de pestaña para explorar rituales y perspectivas"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Passez dun onglet à lautre pour explorer les rituels et les aperçus"
}
}
}
},
"Take 5 deep breaths" : {
"comment" : "Habit title for a ritual preset focused on productivity, encouraging the user to take deep breaths.",
"isCommentAutoGenerated" : true
@ -2608,6 +2550,9 @@
"comment" : "Description of a feature card in the \"WhatsNextStepView\" that explains how to manage all your rituals, regardless of the time they were created.",
"isCommentAutoGenerated" : true
},
"We'll remind you at the perfect times for your morning and evening rituals" : {
"comment" : "Description for notification permission screen when user selected both morning and evening rituals."
},
"Weekly average" : {
"comment" : "Caption for the 7-Day Avg insight card."
},
@ -2623,9 +2568,6 @@
"comment" : "The title of the welcome screen in the setup wizard.",
"isCommentAutoGenerated" : true
},
"We'll remind you at the perfect times for your morning and evening rituals" : {
"comment" : "Description for notification permission screen when user selected both morning and evening rituals."
},
"Wellness" : {
"comment" : "The category of the morning ritual.",
"isCommentAutoGenerated" : true
@ -2676,13 +2618,13 @@
}
}
},
"Wind down with a reminder when it's time for your evening ritual" : {
"comment" : "Description for notification permission screen when user selected evening rituals."
},
"Wind down with habits that promote quality sleep." : {
"comment" : "Notes section of a ritual preset focused on sleep preparation.",
"isCommentAutoGenerated" : true
},
"Wind down with a reminder when it's time for your evening ritual" : {
"comment" : "Description for notification permission screen when user selected evening rituals."
},
"Wind down with intention" : {
"comment" : "Subtitle for the \"Evening\" section of the \"Both\" time preference option in the onboarding flow.",
"isCommentAutoGenerated" : true
@ -2721,6 +2663,18 @@
"comment" : "A hint displayed below the \"Browse Presets\" button, encouraging users to explore preset rituals.",
"isCommentAutoGenerated" : true
},
"You can complete setup later in Settings." : {
"comment" : "Alert message explaining that setup can be completed later in Settings.",
"isCommentAutoGenerated" : true,
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "You can complete setup later in Settings."
}
}
}
},
"You completed your first check-in" : {
"comment" : "A description of the positive outcome of completing a first check-in.",
"isCommentAutoGenerated" : true
@ -2752,29 +2706,6 @@
"comment" : "The title of the preview step in the ritual creation flow.",
"isCommentAutoGenerated" : true
},
"Your focus ritual lives here" : {
"extractionState" : "stale",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Your focus ritual lives here"
}
},
"es-MX" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tu ritual de enfoque vive aquí"
}
},
"fr-CA" : {
"stringUnit" : {
"state" : "translated",
"value" : "Votre rituel principal se trouve ici"
}
}
}
},
"Your highest-performing ritual by completion rate in the current arc. Keep it up!" : {
"comment" : "Explanation for the Best Ritual insight card."
},

View File

@ -66,6 +66,18 @@ enum TimeOfDay: String, Codable, CaseIterable, Comparable {
static func < (lhs: TimeOfDay, rhs: TimeOfDay) -> Bool {
lhs.sortOrder < rhs.sortOrder
}
/// Returns the current time period based on the hour of the day.
static func current(for date: Date = Date()) -> TimeOfDay {
let hour = Calendar.current.component(.hour, from: date)
switch hour {
case 0..<11: return .morning
case 11..<14: return .midday
case 14..<17: return .afternoon
case 17..<21: return .evening
default: return .night
}
}
}
/// A ritual represents a persistent habit-building journey. It contains multiple

View File

@ -3,6 +3,7 @@ import Observation
import SwiftData
import CoreData
import Bedrock
import WidgetKit
@MainActor
@Observable
@ -69,8 +70,12 @@ final class RitualStore: RitualStoreProviding {
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in
self?.reloadRituals()
// Hop to the main actor and capture a strong reference safely there
Task { @MainActor [weak self] in
guard let strongSelf = self else { return }
strongSelf.reloadRituals()
// Also refresh widgets when data arrives from other devices
WidgetCenter.shared.reloadAllTimelines()
}
}
}
@ -166,16 +171,7 @@ final class RitualStore: RitualStoreProviding {
/// Filters based on time of day: morning (before 11am), midday (11am-2pm), afternoon (2pm-5pm),
/// evening (5pm-9pm), night (after 9pm). Anytime rituals are always shown.
func ritualsForToday() -> [Ritual] {
let hour = calendar.component(.hour, from: Date())
let currentPeriod: TimeOfDay = {
switch hour {
case 0..<11: return .morning
case 11..<14: return .midday
case 14..<17: return .afternoon
case 17..<21: return .evening
default: return .night
}
}()
let currentPeriod = TimeOfDay.current()
return currentRituals.filter { ritual in
guard let arc = ritual.currentArc, arc.contains(date: Date()) else { return false }
@ -831,6 +827,8 @@ final class RitualStore: RitualStoreProviding {
do {
try modelContext.save()
reloadRituals()
// Notify widgets that data has changed
WidgetCenter.shared.reloadAllTimelines()
} catch {
lastErrorMessage = error.localizedDescription
}
@ -911,7 +909,7 @@ final class RitualStore: RitualStoreProviding {
}
private func dayIdentifier(for date: Date) -> String {
dayFormatter.string(from: date)
RitualAnalytics.dayIdentifier(for: date)
}
// MARK: - History / Calendar Support

View File

@ -96,8 +96,10 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
private func observeCloudChanges() {
cloudSync.onCloudDataReceived = { [weak self] _ in
Task { @MainActor in
self?.handleCloudDataChange()
}
}
cloudChangeObserver = NotificationCenter.default.addObserver(
forName: .persistedDataDidChange,
@ -109,9 +111,11 @@ final class SettingsStore: CloudSyncable, ThemeProviding {
identifier == AppSettingsData.dataIdentifier else {
return
}
Task { @MainActor in
self.handleCloudDataChange()
}
}
}
private func handleCloudDataChange() {
refreshSettingsData()

View File

@ -85,6 +85,26 @@ struct RootView: View {
store.reminderScheduler.shouldNavigateToToday = false
}
}
.onOpenURL { url in
handleURL(url)
}
}
private func handleURL(_ url: URL) {
guard url.scheme == "andromida" else { return }
switch url.host {
case "today":
selectedTab = .today
case "rituals":
selectedTab = .rituals
case "insights":
selectedTab = .insights
case "history":
selectedTab = .history
default:
break
}
}
private func refreshCurrentTab() {

View File

@ -6,14 +6,7 @@ struct TodayNoRitualsForTimeView: View {
@Bindable var store: RitualStore
private var currentTimePeriod: TimeOfDay {
let hour = Calendar.current.component(.hour, from: Date())
switch hour {
case 0..<11: return .morning
case 11..<14: return .midday
case 14..<17: return .afternoon
case 17..<21: return .evening
default: return .night
}
TimeOfDay.current()
}
private var nextRituals: [Ritual] {

View File

@ -0,0 +1,23 @@
import Foundation
enum AppIdentifiers {
// Read from Info.plist (values come from xcconfig)
static let appGroupIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "AppGroupIdentifier") as? String
?? "group.com.mbrucedogs.Andromida"
}()
static let cloudKitContainerIdentifier: String = {
Bundle.main.object(forInfoDictionaryKey: "CloudKitContainerIdentifier") as? String
?? "iCloud.com.mbrucedogs.Andromida"
}()
// Derived from bundle identifier
static var bundleIdentifier: String {
Bundle.main.bundleIdentifier ?? "com.mbrucedogs.Andromida"
}
static var widgetBundleIdentifier: String {
"\(bundleIdentifier).Widget"
}
}

View File

@ -0,0 +1,25 @@
// Base.xcconfig - Source of truth for all identifiers
// MIGRATION: Update COMPANY_IDENTIFIER and DEVELOPMENT_TEAM below
// =============================================================================
// COMPANY IDENTIFIER - CHANGE THIS FOR MIGRATION
// =============================================================================
COMPANY_IDENTIFIER = com.mbrucedogs
APP_NAME = Andromida
DEVELOPMENT_TEAM = // Add your Team ID here if needed
// =============================================================================
// DERIVED IDENTIFIERS - DO NOT EDIT
// =============================================================================
APP_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)
WATCH_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).watchkitapp
APPCLIP_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Clip
WIDGET_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Widget
INTENT_BUNDLE_IDENTIFIER = $(APP_BUNDLE_IDENTIFIER).Intent
TESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)Tests
UITESTS_BUNDLE_IDENTIFIER = $(COMPANY_IDENTIFIER).$(APP_NAME)UITests
APP_GROUP_IDENTIFIER = group.$(COMPANY_IDENTIFIER).$(APP_NAME)
CLOUDKIT_CONTAINER_IDENTIFIER = iCloud.$(COMPANY_IDENTIFIER).$(APP_NAME)

View File

@ -0,0 +1,6 @@
// Debug.xcconfig
#include "Base.xcconfig"
// Debug-specific settings
SWIFT_OPTIMIZATION_LEVEL = -Onone
ENABLE_TESTABILITY = YES

View File

@ -0,0 +1,6 @@
// Release.xcconfig
#include "Base.xcconfig"
// Release-specific settings
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule

View File

@ -0,0 +1,83 @@
import Foundation
/// Shared logic for ritual analytics and data processing used by both the app and widgets.
enum RitualAnalytics {
/// Returns a unique string identifier for a given date (YYYY-MM-DD).
static func dayIdentifier(for date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
/// Calculates the current streak of consecutive days with activity.
/// - Parameters:
/// - rituals: The list of rituals to analyze.
/// - calendar: The calendar to use for date calculations.
/// - Returns: The current streak count.
static func calculateCurrentStreak(rituals: [Ritual], calendar: Calendar = .current) -> Int {
var allCompletions = Set<String>()
// Collect all completed day IDs from all habits across all rituals
for ritual in rituals {
for arc in ritual.arcs ?? [] {
for habit in arc.habits ?? [] {
for dID in habit.completedDayIDs {
allCompletions.insert(dID)
}
}
}
}
if allCompletions.isEmpty { return 0 }
// Count backwards from today
var streak = 0
var checkDate = calendar.startOfDay(for: Date())
// If today isn't perfect, check if yesterday was to maintain streak
var currentDayID = dayIdentifier(for: checkDate)
if !allCompletions.contains(currentDayID) {
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
currentDayID = dayIdentifier(for: checkDate)
}
while allCompletions.contains(currentDayID) {
streak += 1
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate
currentDayID = dayIdentifier(for: checkDate)
if streak > 3650 { break } // Safety cap (10 years)
}
return streak
}
/// Filters rituals that are active on a specific date and match the current time of day.
static func ritualsActive(on date: Date, from rituals: [Ritual]) -> [Ritual] {
let timeOfDay = TimeOfDay.current(for: date)
return rituals.filter { ritual in
guard ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) != nil else {
return false
}
return ritual.timeOfDay == .anytime || ritual.timeOfDay == timeOfDay
}
}
/// Calculates the overall completion rate for all rituals active on a specific date.
static func overallCompletionRate(on date: Date, from rituals: [Ritual]) -> Double {
let dayID = dayIdentifier(for: date)
var allTodayHabits: [ArcHabit] = []
for ritual in rituals {
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: date) }) {
allTodayHabits.append(contentsOf: arc.habits ?? [])
}
}
guard !allTodayHabits.isEmpty else { return 0.0 }
let completedCount = allTodayHabits.filter { $0.completedDayIDs.contains(dayID) }.count
return Double(completedCount) / Double(allTodayHabits.count)
}
}

View File

@ -0,0 +1,10 @@
<?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.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,17 @@
import WidgetKit
import SwiftUI
@main
struct AndromidaWidget: Widget {
let kind: String = "AndromidaWidget"
var body: some WidgetConfiguration {
AppIntentConfiguration(kind: kind, intent: ConfigurationAppIntent.self, provider: AndromidaWidgetProvider()) { entry in
AndromidaWidgetView(entry: entry)
}
.configurationDisplayName("Andromida")
.description("Track your daily rituals and habits.")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
.contentMarginsDisabled()
}
}

View File

@ -0,0 +1,15 @@
<?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>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>AppGroupIdentifier</key>
<string>$(APP_GROUP_IDENTIFIER)</string>
<key>CloudKitContainerIdentifier</key>
<string>$(CLOUDKIT_CONTAINER_IDENTIFIER)</string>
</dict>
</plist>

View File

@ -0,0 +1,9 @@
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Andromida" }
static var description: IntentDescription { "Track your daily rituals and habits." }
// We can add more parameters here later, like selecting a specific ritual.
}

View File

@ -0,0 +1,9 @@
import Foundation
struct HabitEntry: Identifiable {
let id: UUID
let title: String
let symbolName: String
let ritualTitle: String
let isCompleted: Bool
}

View File

@ -0,0 +1,14 @@
import WidgetKit
import Foundation
struct WidgetEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationAppIntent
let completionRate: Double
let currentStreak: Int
let nextHabits: [HabitEntry]
let weeklyTrend: [Double]
let currentTimeOfDay: String
let currentTimeOfDaySymbol: String
let currentTimeOfDayRange: String
}

View File

@ -0,0 +1,114 @@
import WidgetKit
import SwiftUI
import SwiftData
import AppIntents
struct AndromidaWidgetProvider: AppIntentTimelineProvider {
func placeholder(in context: Context) -> WidgetEntry {
WidgetEntry(
date: Date(),
configuration: ConfigurationAppIntent(),
completionRate: 0.75,
currentStreak: 5,
nextHabits: [
HabitEntry(id: UUID(), title: "Morning Meditation", symbolName: "figure.mind.and.body", ritualTitle: "Mindfulness", isCompleted: false),
HabitEntry(id: UUID(), title: "Drink Water", symbolName: "drop.fill", ritualTitle: "Health", isCompleted: true)
],
weeklyTrend: [0.5, 0.7, 0.6, 0.9, 0.8, 0.75, 0.0],
currentTimeOfDay: "Morning",
currentTimeOfDaySymbol: "sunrise.fill",
currentTimeOfDayRange: "Before 11am"
)
}
func snapshot(for configuration: ConfigurationAppIntent, in context: Context) async -> WidgetEntry {
await fetchLatestData(for: configuration)
}
func timeline(for configuration: ConfigurationAppIntent, in context: Context) async -> Timeline<WidgetEntry> {
let entry = await fetchLatestData(for: configuration)
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: Date()) ?? Date()
return Timeline(entries: [entry], policy: .after(nextUpdate))
}
@MainActor
private func fetchLatestData(for configuration: ConfigurationAppIntent) -> WidgetEntry {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configurationURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
let modelConfig = ModelConfiguration(schema: schema, url: configurationURL)
do {
let container = try ModelContainer(for: schema, configurations: [modelConfig])
let context = container.mainContext
let descriptor = FetchDescriptor<Ritual>()
let rituals = try context.fetch(descriptor)
let today = Date()
let dayID = RitualAnalytics.dayIdentifier(for: today)
let timeOfDay = TimeOfDay.current(for: today)
// Match the app's logic for "Today" view
let todayRituals = RitualAnalytics.ritualsActive(on: today, from: rituals)
.sorted { lhs, rhs in
if lhs.timeOfDay != rhs.timeOfDay {
return lhs.timeOfDay < rhs.timeOfDay
}
return lhs.sortIndex < rhs.sortIndex
}
var visibleHabits: [HabitEntry] = []
for ritual in todayRituals {
if let arc = ritual.arcs?.first(where: { $0.isActive && $0.contains(date: today) }) {
// Sort habits within each ritual by their sortIndex
let sortedHabits = (arc.habits ?? []).sorted { $0.sortIndex < $1.sortIndex }
for habit in sortedHabits {
visibleHabits.append(HabitEntry(
id: habit.id,
title: habit.title,
symbolName: habit.symbolName,
ritualTitle: ritual.title,
isCompleted: habit.completedDayIDs.contains(dayID)
))
}
}
}
// Calculate overall progress across ALL rituals for today
let overallRate = RitualAnalytics.overallCompletionRate(on: today, from: rituals)
// Next habits (limit to 4) - still filtered by current time of day for the list
let nextHabits = visibleHabits.prefix(4)
// Streak calculation
let streak = RitualAnalytics.calculateCurrentStreak(rituals: rituals)
return WidgetEntry(
date: today,
configuration: configuration,
completionRate: overallRate,
currentStreak: streak,
nextHabits: Array(nextHabits),
weeklyTrend: [],
currentTimeOfDay: timeOfDay.displayName,
currentTimeOfDaySymbol: timeOfDay.symbolName,
currentTimeOfDayRange: timeOfDay.timeRange
)
} catch {
// Return a default entry instead of placeholder(in: .preview)
return WidgetEntry(
date: Date(),
configuration: configuration,
completionRate: 0.0,
currentStreak: 0,
nextHabits: [],
weeklyTrend: [],
currentTimeOfDay: "Today",
currentTimeOfDaySymbol: "clock.fill",
currentTimeOfDayRange: ""
)
}
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,30 @@
import SwiftUI
import WidgetKit
struct AndromidaWidgetView: View {
var entry: WidgetEntry
@Environment(\.widgetFamily) var family
var body: some View {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "andromida://today"))
case .systemMedium:
MediumWidgetView(entry: entry)
.widgetURL(URL(string: "andromida://today"))
case .systemLarge:
LargeWidgetView(entry: entry)
.widgetURL(URL(string: "andromida://today"))
default:
SmallWidgetView(entry: entry)
.widgetURL(URL(string: "andromida://today"))
}
}
}
// MARK: - Branding Colors Helper
extension Color {
static let brandingPrimary = Color(red: 0.12, green: 0.09, blue: 0.08)
static let brandingAccent = Color(red: 0.95, green: 0.60, blue: 0.45) // Matches the orange-ish accent in your app
}

View File

@ -0,0 +1,78 @@
import SwiftUI
import WidgetKit
import Bedrock
struct LargeWidgetView: View {
let entry: WidgetEntry
var body: some View {
VStack(alignment: .leading, spacing: Design.Spacing.large) {
HStack {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(String(localized: "Today's Progress"))
.styled(.heading, emphasis: .custom(.white))
Text("\(entry.currentStreak) day streak")
.styled(.subheading, emphasis: .custom(Color.brandingAccent))
}
Spacer()
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 6)
Circle()
.trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 6, lineCap: .round))
.rotationEffect(.degrees(-90))
Text("\(Int(entry.completionRate * 100))%")
.styled(.captionEmphasis, emphasis: .custom(.white))
}
.frame(width: 50, height: 50)
}
Divider()
.background(Color.white.opacity(0.2))
if entry.nextHabits.isEmpty {
Spacer()
WidgetEmptyStateView(
title: String(localized: "No rituals scheduled for \(entry.currentTimeOfDay.lowercased())."),
subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange
)
Spacer()
} else {
Text(String(localized: "Habits"))
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7)))
VStack(spacing: Design.Spacing.medium) {
ForEach(entry.nextHabits) { habit in
HStack(spacing: Design.Spacing.medium) {
Image(systemName: habit.symbolName)
.foregroundColor(Color.brandingAccent)
.font(.system(size: 18))
.frame(width: 24)
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
Text(habit.title)
.styled(.subheading, emphasis: .custom(.white))
Text(habit.ritualTitle)
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
}
Spacer()
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundColor(habit.isCompleted ? .green : .white.opacity(0.2))
.font(.system(size: 20))
}
}
}
}
Spacer()
}
.padding(Design.Spacing.large)
.containerBackground(for: .widget) {
Color.brandingPrimary
}
}
}

View File

@ -0,0 +1,79 @@
import SwiftUI
import WidgetKit
import Bedrock
struct MediumWidgetView: View {
let entry: WidgetEntry
var body: some View {
HStack(spacing: 0) {
// Left side: Progress & Streak
VStack(spacing: Design.Spacing.medium) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 8)
Circle()
.trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(Int(entry.completionRate * 100))%")
.styled(.heading, emphasis: .custom(.white))
Text(String(localized: "Today"))
.styled(.caption, emphasis: .custom(.white.opacity(0.7)))
}
}
.frame(width: 72, height: 72)
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill")
.foregroundColor(Color.brandingAccent)
.font(.system(size: 14))
Text("\(entry.currentStreak)d")
.styled(.captionEmphasis, emphasis: .custom(.white))
}
}
.frame(width: 110)
// Right side: Habits
VStack(alignment: .leading, spacing: Design.Spacing.medium) {
if entry.nextHabits.isEmpty {
WidgetEmptyStateView(
title: String(localized: "No rituals now"),
subtitle: entry.currentTimeOfDay,
symbolName: entry.currentTimeOfDaySymbol,
timeRange: entry.currentTimeOfDayRange
)
} else {
Text(String(localized: "Next Habits"))
.styled(.captionEmphasis, emphasis: .custom(.white.opacity(0.7)))
VStack(alignment: .leading, spacing: Design.Spacing.small) {
ForEach(entry.nextHabits.prefix(3)) { habit in
HStack(spacing: Design.Spacing.small) {
Image(systemName: habit.isCompleted ? "checkmark.circle.fill" : habit.symbolName)
.foregroundColor(habit.isCompleted ? .green : Color.brandingAccent)
.font(.system(size: 14))
.frame(width: 20)
Text(habit.title)
.styled(.subheading, emphasis: .custom(.white))
.lineLimit(1)
}
}
}
}
Spacer()
}
.padding(.vertical, Design.Spacing.large)
.padding(.trailing, Design.Spacing.medium)
Spacer()
}
.containerBackground(for: .widget) {
Color.brandingPrimary
}
}
}

View File

@ -0,0 +1,38 @@
import SwiftUI
import WidgetKit
import Bedrock
struct SmallWidgetView: View {
let entry: WidgetEntry
var body: some View {
VStack(spacing: Design.Spacing.small) {
ZStack {
Circle()
.stroke(Color.white.opacity(0.1), lineWidth: 8)
Circle()
.trim(from: 0, to: entry.completionRate)
.stroke(Color.brandingAccent, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(Int(entry.completionRate * 100))%")
.styled(.heading, emphasis: .custom(.white))
Text(String(localized: "Today"))
.styled(.caption, emphasis: .custom(.white.opacity(0.7)))
}
}
.frame(width: 80, height: 80)
HStack(spacing: Design.Spacing.xSmall) {
Image(systemName: "flame.fill")
.foregroundColor(Color.brandingAccent)
Text("\(entry.currentStreak) day streak")
.styled(.captionEmphasis, emphasis: .custom(.white))
}
}
.containerBackground(for: .widget) {
Color.brandingPrimary
}
}
}

View File

@ -0,0 +1,31 @@
import SwiftUI
import Bedrock
struct WidgetEmptyStateView: View {
let title: String
let subtitle: String
let symbolName: String
let timeRange: String
var body: some View {
VStack(spacing: Design.Spacing.small) {
SymbolIcon(symbolName, size: .hero, color: Color.brandingAccent.opacity(0.6))
VStack(spacing: Design.Spacing.xSmall) {
Text(title)
.styled(.subheading, emphasis: .custom(.white))
.multilineTextAlignment(.center)
if !timeRange.isEmpty {
Text(timeRange)
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
}
}
Text(String(localized: "Enjoy this moment."))
.styled(.caption, emphasis: .custom(.white.opacity(0.5)))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

View File

@ -0,0 +1,10 @@
<?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.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
</dict>
</plist>