bootstrap bulkhealth moodwell state-of-mind importer mvp

This commit is contained in:
2026-02-19 10:11:24 +01:00
commit 2c5b5c1767
23 changed files with 1642 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.DS_Store
.derivedData/

File diff suppressed because one or more lines are too long

17
AI_AGENT_REPORT.md Normal file
View File

@@ -0,0 +1,17 @@
# AI Agent Report
## Date
- 2026-02-18
## Summary
- Bootstrapped a new iOS SwiftUI app (`BulkHealth`) with HealthKit entitlement and localization scaffolding (`en`, `de`, `es`, `fr`).
- Implemented a reusable import pattern (template fields + mapping + dry-run transform) and a Moodwell-specific adapter for Apple Health State of Mind.
- Added dry-run preview and commit flow with HealthKit authorization/write for `HKStateOfMind` samples.
- Added unit tests for Moodwell transformation logic and invalid date validation.
- Verified app build and test-target build (`build-for-testing`) with local DerivedData.
## Open Items
- Verify UI/flow directly on iPhone with Health permissions and real Apple Health write behavior.
- Expand mapping dictionaries for additional custom Moodwell emotions/activities if needed.
- Add support for additional Apple Health data templates using the same import architecture.
- In this sandbox, `xcodebuild test` cannot run because CoreSimulator service is unavailable.

View File

@@ -0,0 +1,490 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
0B7694AE0FE28B642DF7D0EA /* ImportModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C315594F7577615AE4299D6 /* ImportModels.swift */; };
102F2185E6873FE20EBC0392 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C58AB725F0E46CEA7B16BC12 /* ContentView.swift */; };
1725244D3318135F3A349E1B /* HealthKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6159620F31542C1A0B03E75A /* HealthKitService.swift */; };
1E6C5AFD3A8F328D7F12ADE2 /* MoodwellData.json in Resources */ = {isa = PBXBuildFile; fileRef = 6244F521D755A682362FE647 /* MoodwellData.json */; };
2CF905CDCD4E76F59990DA53 /* BulkHealthApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C3BB40556E06CA90DF78CE4 /* BulkHealthApp.swift */; };
60A88E25306111B096760B43 /* ImportFlowViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC7CEDF1D80E0D342B994343 /* ImportFlowViewModel.swift */; };
B24496C3D0C604C99D3F079B /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 3BD4AD38428A5AAA87D628C3 /* Localizable.strings */; };
C9D09732E786624DDD7DA520 /* MoodwellStateOfMindTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EE805B262EBB414E1BAF60B /* MoodwellStateOfMindTemplate.swift */; };
CD727A4847E448D49660C34B /* BulkHealthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157B89F92D6B26E186902D96 /* BulkHealthTests.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
6466463E714229889E5435F1 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 25CBEC19EFA4A428CE1F5FE5 /* Project object */;
proxyType = 1;
remoteGlobalIDString = E8B3CD9ECDD626874CE7D0BB;
remoteInfo = BulkHealth;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
157B89F92D6B26E186902D96 /* BulkHealthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkHealthTests.swift; sourceTree = "<group>"; };
3871CE97DA417A2FE4031C4B /* BulkHealth.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BulkHealth.entitlements; sourceTree = "<group>"; };
4C3BB40556E06CA90DF78CE4 /* BulkHealthApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BulkHealthApp.swift; sourceTree = "<group>"; };
502E6FE75A5832E2520F152F /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/Localizable.strings; sourceTree = "<group>"; };
6159620F31542C1A0B03E75A /* HealthKitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitService.swift; sourceTree = "<group>"; };
6244F521D755A682362FE647 /* MoodwellData.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = MoodwellData.json; sourceTree = "<group>"; };
7C315594F7577615AE4299D6 /* ImportModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportModels.swift; sourceTree = "<group>"; };
7EE805B262EBB414E1BAF60B /* MoodwellStateOfMindTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoodwellStateOfMindTemplate.swift; sourceTree = "<group>"; };
8E2D77BAE6A93846EBB69369 /* BulkHealth.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = BulkHealth.app; sourceTree = BUILT_PRODUCTS_DIR; };
9BB95915C4BA5670D348348E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
A4F08640D1164270AB69287E /* BulkHealthTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = BulkHealthTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A64618DC7DC27A571030D2C0 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
AC7CEDF1D80E0D342B994343 /* ImportFlowViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportFlowViewModel.swift; sourceTree = "<group>"; };
C3FECC5F7177706011A561D8 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
C58AB725F0E46CEA7B16BC12 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
CFE0E8E8E5D9204E202A0D24 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
0B9E047A61815487A18D4078 /* Services */ = {
isa = PBXGroup;
children = (
6159620F31542C1A0B03E75A /* HealthKitService.swift */,
);
path = Services;
sourceTree = "<group>";
};
11E0721B8A4A36711DBD470E /* Resources */ = {
isa = PBXGroup;
children = (
3BD4AD38428A5AAA87D628C3 /* Localizable.strings */,
6244F521D755A682362FE647 /* MoodwellData.json */,
);
path = Resources;
sourceTree = "<group>";
};
584C55195C916FB206E2D0C0 /* BulkHealthApp */ = {
isa = PBXGroup;
children = (
3871CE97DA417A2FE4031C4B /* BulkHealth.entitlements */,
4C3BB40556E06CA90DF78CE4 /* BulkHealthApp.swift */,
C58AB725F0E46CEA7B16BC12 /* ContentView.swift */,
9BB95915C4BA5670D348348E /* Info.plist */,
E721EFE7386687716D780807 /* Import */,
11E0721B8A4A36711DBD470E /* Resources */,
0B9E047A61815487A18D4078 /* Services */,
);
path = BulkHealthApp;
sourceTree = "<group>";
};
7CEEA8A614790D2B318AE0C5 /* Products */ = {
isa = PBXGroup;
children = (
8E2D77BAE6A93846EBB69369 /* BulkHealth.app */,
A4F08640D1164270AB69287E /* BulkHealthTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
8D9B6C0759A8CBFA40BE2534 = {
isa = PBXGroup;
children = (
584C55195C916FB206E2D0C0 /* BulkHealthApp */,
A98E9E82FD7D6171E4C2CB0B /* BulkHealthTests */,
7CEEA8A614790D2B318AE0C5 /* Products */,
);
sourceTree = "<group>";
};
A98E9E82FD7D6171E4C2CB0B /* BulkHealthTests */ = {
isa = PBXGroup;
children = (
157B89F92D6B26E186902D96 /* BulkHealthTests.swift */,
);
path = BulkHealthTests;
sourceTree = "<group>";
};
E721EFE7386687716D780807 /* Import */ = {
isa = PBXGroup;
children = (
AC7CEDF1D80E0D342B994343 /* ImportFlowViewModel.swift */,
7C315594F7577615AE4299D6 /* ImportModels.swift */,
7EE805B262EBB414E1BAF60B /* MoodwellStateOfMindTemplate.swift */,
);
path = Import;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
4F2EC35850C71410A58DFBA1 /* BulkHealthTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 8510CCD1DAABEAF58A082E7E /* Build configuration list for PBXNativeTarget "BulkHealthTests" */;
buildPhases = (
F846F7D72CAC1AC679EBEAD2 /* Sources */,
);
buildRules = (
);
dependencies = (
D3341900B3B0368CD8A5D435 /* PBXTargetDependency */,
);
name = BulkHealthTests;
packageProductDependencies = (
);
productName = BulkHealthTests;
productReference = A4F08640D1164270AB69287E /* BulkHealthTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
E8B3CD9ECDD626874CE7D0BB /* BulkHealth */ = {
isa = PBXNativeTarget;
buildConfigurationList = ADFB8E4411C4C9F962904EA6 /* Build configuration list for PBXNativeTarget "BulkHealth" */;
buildPhases = (
6ACDF990A8D3B2107A599382 /* Sources */,
A5673340CA3F2EEA3C293FF5 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = BulkHealth;
packageProductDependencies = (
);
productName = BulkHealth;
productReference = 8E2D77BAE6A93846EBB69369 /* BulkHealth.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
25CBEC19EFA4A428CE1F5FE5 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1430;
TargetAttributes = {
4F2EC35850C71410A58DFBA1 = {
DevelopmentTeam = NG5W75WE8U;
ProvisioningStyle = Automatic;
};
E8B3CD9ECDD626874CE7D0BB = {
DevelopmentTeam = NG5W75WE8U;
ProvisioningStyle = Automatic;
};
};
};
buildConfigurationList = EA4CE42E0F2800A333C6F0F4 /* Build configuration list for PBXProject "BulkHealth" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
de,
en,
es,
fr,
);
mainGroup = 8D9B6C0759A8CBFA40BE2534;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
projectDirPath = "";
projectRoot = "";
targets = (
E8B3CD9ECDD626874CE7D0BB /* BulkHealth */,
4F2EC35850C71410A58DFBA1 /* BulkHealthTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
A5673340CA3F2EEA3C293FF5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B24496C3D0C604C99D3F079B /* Localizable.strings in Resources */,
1E6C5AFD3A8F328D7F12ADE2 /* MoodwellData.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
6ACDF990A8D3B2107A599382 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
2CF905CDCD4E76F59990DA53 /* BulkHealthApp.swift in Sources */,
102F2185E6873FE20EBC0392 /* ContentView.swift in Sources */,
1725244D3318135F3A349E1B /* HealthKitService.swift in Sources */,
60A88E25306111B096760B43 /* ImportFlowViewModel.swift in Sources */,
0B7694AE0FE28B642DF7D0EA /* ImportModels.swift in Sources */,
C9D09732E786624DDD7DA520 /* MoodwellStateOfMindTemplate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
F846F7D72CAC1AC679EBEAD2 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
CD727A4847E448D49660C34B /* BulkHealthTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
D3341900B3B0368CD8A5D435 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = E8B3CD9ECDD626874CE7D0BB /* BulkHealth */;
targetProxy = 6466463E714229889E5435F1 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
3BD4AD38428A5AAA87D628C3 /* Localizable.strings */ = {
isa = PBXVariantGroup;
children = (
502E6FE75A5832E2520F152F /* de */,
A64618DC7DC27A571030D2C0 /* en */,
CFE0E8E8E5D9204E202A0D24 /* es */,
C3FECC5F7177706011A561D8 /* fr */,
);
name = Localizable.strings;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
5BC4CA409BE6B6A40F8E5DEE /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BulkHealth.app/BulkHealth";
};
name = Debug;
};
5C756BE31A8D2B2FB1D5360E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
GENERATE_INFOPLIST_FILE = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/BulkHealth.app/BulkHealth";
};
name = Release;
};
9F58AF7EC744E721E31A83AA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BulkHealthApp/BulkHealth.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = BulkHealthApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BulkHealth;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Debug;
};
A943017A0D2E4797E165E46C /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = NG5W75WE8U;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2026.02.18;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.bulkhealth;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0;
};
name = Debug;
};
C6B8E40DF53B3917B06A25A5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = NG5W75WE8U;
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 2026.02.18;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = de.felixfoertsch.bulkhealth;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0;
};
name = Release;
};
F338489327D2FD8C1AD164EF /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = BulkHealthApp/BulkHealth.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = BulkHealthApp/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = BulkHealth;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = 1;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
8510CCD1DAABEAF58A082E7E /* Build configuration list for PBXNativeTarget "BulkHealthTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5BC4CA409BE6B6A40F8E5DEE /* Debug */,
5C756BE31A8D2B2FB1D5360E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
ADFB8E4411C4C9F962904EA6 /* Build configuration list for PBXNativeTarget "BulkHealth" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9F58AF7EC744E721E31A83AA /* Debug */,
F338489327D2FD8C1AD164EF /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
EA4CE42E0F2800A333C6F0F4 /* Build configuration list for PBXProject "BulkHealth" */ = {
isa = XCConfigurationList;
buildConfigurations = (
A943017A0D2E4797E165E46C /* Debug */,
C6B8E40DF53B3917B06A25A5 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
};
rootObject = 25CBEC19EFA4A428CE1F5FE5 /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1430"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E8B3CD9ECDD626874CE7D0BB"
BuildableName = "BulkHealth.app"
BlueprintName = "BulkHealth"
ReferencedContainer = "container:BulkHealth.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E8B3CD9ECDD626874CE7D0BB"
BuildableName = "BulkHealth.app"
BlueprintName = "BulkHealth"
ReferencedContainer = "container:BulkHealth.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "4F2EC35850C71410A58DFBA1"
BuildableName = "BulkHealthTests.xctest"
BlueprintName = "BulkHealthTests"
ReferencedContainer = "container:BulkHealth.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E8B3CD9ECDD626874CE7D0BB"
BuildableName = "BulkHealth.app"
BlueprintName = "BulkHealth"
ReferencedContainer = "container:BulkHealth.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E8B3CD9ECDD626874CE7D0BB"
BuildableName = "BulkHealth.app"
BlueprintName = "BulkHealth"
ReferencedContainer = "container:BulkHealth.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View 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.healthkit</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1,12 @@
import SwiftUI
@main
struct BulkHealthApp: App {
@StateObject private var viewModel = ImportFlowViewModel()
var body: some Scene {
WindowGroup {
ContentView(viewModel: viewModel)
}
}
}

View File

@@ -0,0 +1,188 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel: ImportFlowViewModel
@State private var showingFileImporter = false
var body: some View {
NavigationStack {
Form {
templateSection
sourceSection
mappingSection
moodScaleSection
dryRunSection
commitSection
}
.navigationTitle(String(localized: "screen.title.import"))
.fileImporter(
isPresented: $showingFileImporter,
allowedContentTypes: viewModel.supportedContentTypes,
allowsMultipleSelection: false
) { result in
switch result {
case let .success(urls):
if let url = urls.first {
viewModel.loadFile(from: url)
}
case let .failure(error):
viewModel.errorMessage = error.localizedDescription
}
}
.alert(String(localized: "error.title"), isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { isVisible in
if !isVisible {
viewModel.errorMessage = nil
}
}
), actions: {
Button(String(localized: "button.ok"), role: .cancel) {}
}, message: {
Text(viewModel.errorMessage ?? "")
})
}
}
private var templateSection: some View {
Section(String(localized: "section.template")) {
LabeledContent(String(localized: "label.health_segment"), value: viewModel.template.localizedHealthTypeName)
LabeledContent(String(localized: "label.template"), value: viewModel.template.title)
ForEach(viewModel.template.fields) { field in
VStack(alignment: .leading, spacing: 4) {
Text(field.title)
.font(.headline)
Text(field.description)
.font(.caption)
.foregroundStyle(.secondary)
}
.accessibilityElement(children: .combine)
}
}
}
private var sourceSection: some View {
Section(String(localized: "section.source")) {
Button(String(localized: "button.load_json")) {
showingFileImporter = true
}
Button(String(localized: "button.load_sample")) {
viewModel.loadBundledSample()
}
Text(String.localizedStringWithFormat(
NSLocalizedString("label.records_loaded", comment: ""),
viewModel.entries.count
))
if !viewModel.statusMessage.isEmpty {
Text(viewModel.statusMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
private var mappingSection: some View {
Section(String(localized: "section.mapping")) {
if viewModel.sourceKeys.isEmpty {
Text(String(localized: "text.mapping.unavailable"))
foregroundStyle(.secondary)
} else {
ForEach(viewModel.template.fields) { field in
Picker(field.title, selection: Binding(
get: { viewModel.mapping.sourceKey(for: field.id) },
set: { newValue in
viewModel.mapping.sourceKeyByFieldID[field.id] = newValue
}
)) {
ForEach(mappingOptions(for: field), id: \.self) { sourceKey in
Text(sourceKey).tag(sourceKey)
}
}
}
}
}
}
private var moodScaleSection: some View {
Section(String(localized: "section.scale")) {
ForEach([1, 2, 3, 4], id: \.self) { moodType in
HStack {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.mood_type_scale", comment: ""),
moodType
))
Spacer()
Text(String(format: "%.2f", viewModel.moodValenceScale[moodType, default: 0]))
}
Slider(value: Binding(
get: { viewModel.moodValenceScale[moodType, default: 0] },
set: { viewModel.moodValenceScale[moodType] = $0 }
), in: -1...1, step: 0.05)
.accessibilityLabel(Text("Mood type \(moodType) valence"))
}
}
}
private var dryRunSection: some View {
Section(String(localized: "section.dry_run")) {
Button(String(localized: "button.run_dry_run")) {
viewModel.runDryRun()
}
if let result = viewModel.dryRunResult {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.preview_count", comment: ""),
result.drafts.count
))
if !result.errors.isEmpty {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.errors_count", comment: ""),
result.errors.count
))
.foregroundStyle(.red)
}
if !result.warnings.isEmpty {
Text(String.localizedStringWithFormat(
NSLocalizedString("label.warnings_count", comment: ""),
result.warnings.count
))
.foregroundStyle(.orange)
}
ForEach(Array(result.drafts.prefix(15))) { draft in
Text(draft.summaryText)
.font(.caption)
}
}
}
}
private var commitSection: some View {
Section(String(localized: "section.commit")) {
Button {
Task {
await viewModel.commitImport()
}
} label: {
if viewModel.isImporting {
ProgressView()
} else {
Text(String(localized: "button.commit"))
}
}
.disabled(viewModel.isImporting)
Text(String(localized: "text.commit_notice"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
private func mappingOptions(for field: TemplateField) -> [String] {
let selected = viewModel.mapping.sourceKey(for: field.id)
if selected.isEmpty {
return viewModel.sourceKeys
}
if viewModel.sourceKeys.contains(selected) {
return viewModel.sourceKeys
}
return [selected] + viewModel.sourceKeys
}
}

View File

@@ -0,0 +1,95 @@
import Foundation
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class ImportFlowViewModel: ObservableObject {
@Published var template = MoodwellStateOfMindTemplate.template
@Published var entries: [MoodwellEntry] = []
@Published var sourceKeys: [String] = []
@Published var mapping = MoodwellStateOfMindTemplate.defaultMapping
@Published var moodValenceScale: [Int: Double] = [1: -0.8, 2: -0.25, 3: 0.3, 4: 0.8]
@Published var dryRunResult: DryRunResult?
@Published var isImporting = false
@Published var statusMessage = ""
@Published var errorMessage: String?
let supportedContentTypes: [UTType] = [.json]
private let healthKitService = HealthKitService()
init() {
loadBundledSample()
}
func loadBundledSample() {
guard let url = Bundle.main.url(forResource: "MoodwellData", withExtension: "json") else {
errorMessage = String(localized: "error.sample.missing")
return
}
loadFile(from: url)
}
func loadFile(from url: URL) {
do {
let data = try Data(contentsOf: url)
let payload = try JSONDecoder().decode(MoodwellPayload.self, from: data)
entries = payload.mymoods
if let firstEntry = payload.mymoods.first {
sourceKeys = Array(firstEntry.sourceDictionary.keys).sorted()
} else {
sourceKeys = []
}
dryRunResult = nil
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.loaded", comment: ""),
entries.count
)
errorMessage = nil
} catch {
errorMessage = String(localized: "error.load.file") + " \(error.localizedDescription)"
}
}
func runDryRun() {
let result = MoodwellStateOfMindTemplate.makeDrafts(entries: entries, mapping: mapping, moodValenceScale: moodValenceScale)
dryRunResult = result
if result.errors.isEmpty {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.success", comment: ""),
result.drafts.count
)
} else {
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.dryrun.withErrors", comment: ""),
result.errors.count
)
}
}
func commitImport() async {
guard let dryRunResult else {
errorMessage = String(localized: "error.dryrun.required")
return
}
guard dryRunResult.errors.isEmpty else {
errorMessage = String(localized: "error.dryrun.fix")
return
}
isImporting = true
defer { isImporting = false }
do {
try await healthKitService.requestWriteAccessForStateOfMind()
try await healthKitService.save(dryRunResult.drafts)
statusMessage = String.localizedStringWithFormat(
NSLocalizedString("status.import.success", comment: ""),
dryRunResult.drafts.count
)
errorMessage = nil
} catch {
errorMessage = String(localized: "error.import.failed") + " \(error.localizedDescription)"
}
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
import HealthKit
struct TemplateField: Identifiable, Hashable {
enum FieldType: String {
case string
case number
case date
case stringArray
}
let id: String
let title: String
let description: String
let type: FieldType
let isRequired: Bool
}
struct HealthTemplate {
let id: String
let title: String
let localizedHealthTypeName: String
let fields: [TemplateField]
}
struct ImportMapping: Equatable {
var sourceKeyByFieldID: [String: String]
func sourceKey(for fieldID: String) -> String {
sourceKeyByFieldID[fieldID, default: ""]
}
}
struct MoodwellPayload: Decodable {
let mymoods: [MoodwellEntry]
}
struct MoodwellEntry: Decodable, Identifiable {
let arrayOfPhotos: [String]
let moodType: Int
let moodUniqueIdentifier: String
let arrayOfBadEmotions: [String]
let notesString: String?
let arrayOfWeathers: [String]
let createdAt: String
let arrayOfActivities: [String]
let arrayOfGoodEmotions: [String]
var id: String { moodUniqueIdentifier }
var sourceDictionary: [String: ImportValue] {
[
"moodType": .number(Double(moodType)),
"moodUniqueIdentifier": .string(moodUniqueIdentifier),
"arrayOfBadEmotions": .stringArray(arrayOfBadEmotions),
"notesString": notesString.map(ImportValue.string) ?? .null,
"arrayOfWeathers": .stringArray(arrayOfWeathers),
"createdAt": .string(createdAt),
"arrayOfActivities": .stringArray(arrayOfActivities),
"arrayOfGoodEmotions": .stringArray(arrayOfGoodEmotions)
]
}
}
enum ImportValue: Hashable {
case string(String)
case number(Double)
case stringArray([String])
case null
func asString() -> String? {
if case let .string(value) = self {
return value
}
return nil
}
func asNumber() -> Double? {
if case let .number(value) = self {
return value
}
return nil
}
func asStringArray() -> [String]? {
if case let .stringArray(value) = self {
return value
}
return nil
}
}
struct StateOfMindDraft: Identifiable, Hashable {
let id: String
let date: Date
let kind: HKStateOfMind.Kind
let valence: Double
let labels: [HKStateOfMind.Label]
let associations: [HKStateOfMind.Association]
let metadata: [String: String]
var summaryText: String {
"\(date.formatted(date: .abbreviated, time: .shortened))\(kind == .dailyMood ? "Daily mood" : "Momentary emotion") • valence \(String(format: "%.2f", valence))"
}
}
struct DryRunResult {
let drafts: [StateOfMindDraft]
let warnings: [String]
let errors: [String]
}

View File

@@ -0,0 +1,183 @@
import Foundation
import HealthKit
enum MoodwellStateOfMindTemplate {
static let template = HealthTemplate(
id: "state_of_mind",
title: String(localized: "template.title.state_of_mind"),
localizedHealthTypeName: String(localized: "health.segment.state_of_mind"),
fields: [
TemplateField(id: "createdAt", title: String(localized: "field.created_at"), description: String(localized: "field.created_at.description"), type: .date, isRequired: true),
TemplateField(id: "moodType", title: String(localized: "field.mood_type"), description: String(localized: "field.mood_type.description"), type: .number, isRequired: true),
TemplateField(id: "arrayOfGoodEmotions", title: String(localized: "field.good_emotions"), description: String(localized: "field.good_emotions.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfBadEmotions", title: String(localized: "field.bad_emotions"), description: String(localized: "field.bad_emotions.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfActivities", title: String(localized: "field.activities"), description: String(localized: "field.activities.description"), type: .stringArray, isRequired: false),
TemplateField(id: "arrayOfWeathers", title: String(localized: "field.weather"), description: String(localized: "field.weather.description"), type: .stringArray, isRequired: false),
TemplateField(id: "notesString", title: String(localized: "field.notes"), description: String(localized: "field.notes.description"), type: .string, isRequired: false),
TemplateField(id: "moodUniqueIdentifier", title: String(localized: "field.external_id"), description: String(localized: "field.external_id.description"), type: .string, isRequired: false)
]
)
static let defaultMapping = ImportMapping(sourceKeyByFieldID: [
"createdAt": "createdAt",
"moodType": "moodType",
"arrayOfGoodEmotions": "arrayOfGoodEmotions",
"arrayOfBadEmotions": "arrayOfBadEmotions",
"arrayOfActivities": "arrayOfActivities",
"arrayOfWeathers": "arrayOfWeathers",
"notesString": "notesString",
"moodUniqueIdentifier": "moodUniqueIdentifier"
])
static let emotionToLabel: [String: HKStateOfMind.Label] = [
"angry": .angry,
"anxious": .anxious,
"ashamed": .ashamed,
"confident": .confident,
"content": .content,
"down": .sad,
"empty": .hopeless,
"excited": .excited,
"frustrated": .frustrated,
"grateful": .grateful,
"guilty": .guilty,
"happy": .happy,
"interested": .hopeful,
"joy": .joyful,
"lonely": .lonely,
"loved": .peaceful,
"loving": .passionate,
"nervous": .worried,
"optimistic": .hopeful,
"proud": .proud,
"relaxed": .calm,
"relieved": .relieved,
"remorse": .guilty,
"sad": .sad,
"scared": .scared,
"stressed": .stressed,
"surprised": .surprised,
"tired": .drained,
"numb": .indifferent,
"confused": .overwhelmed,
"sick": .discouraged,
"chill": .peaceful
]
static let activityToAssociation: [String: HKStateOfMind.Association] = [
"work": .work,
"sports": .fitness,
"exercise": .fitness,
"swimming": .fitness,
"family": .family,
"friends": .friends,
"date": .dating,
"traveling": .travel,
"reading": .education,
"studying": .education,
"coding": .tasks,
"cleaning": .tasks,
"laundry": .tasks,
"shopping": .money,
"meditation": .selfCare,
"yoga": .selfCare,
"sleep": .selfCare,
"argument": .community,
"fighting": .community,
"sex": .partner,
"porn": .partner
]
static func makeDrafts(
entries: [MoodwellEntry],
mapping: ImportMapping,
moodValenceScale: [Int: Double]
) -> DryRunResult {
let dateFormatter = ISO8601DateFormatter()
var drafts: [StateOfMindDraft] = []
var warnings: [String] = []
var errors: [String] = []
for entry in entries {
let source = entry.sourceDictionary
guard
let dateKey = mapping.sourceKeyByFieldID["createdAt"],
let dateValue = source[dateKey]?.asString(),
let date = dateFormatter.date(from: dateValue)
else {
errors.append("Missing or invalid date for record \(entry.moodUniqueIdentifier).")
continue
}
guard
let moodTypeKey = mapping.sourceKeyByFieldID["moodType"],
let moodTypeValue = source[moodTypeKey]?.asNumber()
else {
errors.append("Missing moodType for record \(entry.moodUniqueIdentifier).")
continue
}
let moodTypeInt = Int(moodTypeValue)
guard let valence = moodValenceScale[moodTypeInt] else {
errors.append("No valence mapping for moodType \(moodTypeInt) on record \(entry.moodUniqueIdentifier).")
continue
}
let goodKey = mapping.sourceKeyByFieldID["arrayOfGoodEmotions", default: ""]
let badKey = mapping.sourceKeyByFieldID["arrayOfBadEmotions", default: ""]
let emotionInputs = (source[goodKey]?.asStringArray() ?? []) + (source[badKey]?.asStringArray() ?? [])
let mappedLabels = Set(emotionInputs.compactMap { emotion in
MoodwellStateOfMindTemplate.emotionToLabel[emotion.lowercased()]
})
let unknownEmotions = emotionInputs.filter { MoodwellStateOfMindTemplate.emotionToLabel[$0.lowercased()] == nil }
if !unknownEmotions.isEmpty {
warnings.append("Unmapped emotions on \(entry.moodUniqueIdentifier): \(unknownEmotions.joined(separator: ", ")).")
}
let activityKey = mapping.sourceKeyByFieldID["arrayOfActivities", default: ""]
let weatherKey = mapping.sourceKeyByFieldID["arrayOfWeathers", default: ""]
let activityInputs = source[activityKey]?.asStringArray() ?? []
let weatherInputs = source[weatherKey]?.asStringArray() ?? []
var associations = Set(activityInputs.compactMap { activity in
MoodwellStateOfMindTemplate.activityToAssociation[activity.lowercased()]
})
if !weatherInputs.isEmpty {
associations.insert(.weather)
}
let externalIDKey = mapping.sourceKeyByFieldID["moodUniqueIdentifier", default: ""]
let notesKey = mapping.sourceKeyByFieldID["notesString", default: ""]
let externalID = source[externalIDKey]?.asString() ?? entry.moodUniqueIdentifier
let notes = source[notesKey]?.asString() ?? ""
var metadata: [String: String] = [
"BulkHealth.Source": "Moodwell",
"BulkHealth.ExternalID": externalID
]
if !notes.isEmpty {
metadata["BulkHealth.Note"] = notes
}
if !activityInputs.isEmpty {
metadata["BulkHealth.Activities"] = activityInputs.joined(separator: ",")
}
if !weatherInputs.isEmpty {
metadata["BulkHealth.Weather"] = weatherInputs.joined(separator: ",")
}
let draft = StateOfMindDraft(
id: entry.id,
date: date,
kind: .dailyMood,
valence: min(1.0, max(-1.0, valence)),
labels: Array(mappedLabels).sorted { $0.rawValue < $1.rawValue },
associations: Array(associations).sorted { $0.rawValue < $1.rawValue },
metadata: metadata
)
drafts.append(draft)
}
return DryRunResult(drafts: drafts.sorted { $0.date < $1.date }, warnings: warnings, errors: errors)
}
}

33
BulkHealthApp/Info.plist Normal file
View File

@@ -0,0 +1,33 @@
<?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>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>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHealthShareUsageDescription</key>
<string>This app reads and previews your selected mental state records for import review.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>This app writes imported mental state records to Apple Health after you confirm.</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
</dict>
</plist>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,53 @@
"screen.title.import" = "Bulk-Health-Import";
"template.title.state_of_mind" = "Gem\U00FCtszustand";
"health.segment.state_of_mind" = "Gem\U00FCtszustand";
"section.template" = "Apple-Health-Vorlage";
"section.source" = "Quelldaten";
"section.mapping" = "Feldzuordnung";
"section.scale" = "Stimmungstyp-Skala";
"section.dry_run" = "Testlauf";
"section.commit" = "Import best\U00E4tigen";
"label.health_segment" = "Health-Segment";
"label.template" = "Vorlage";
"label.records_loaded" = "Geladene Eintr\U00E4ge: %lld";
"label.preview_count" = "Vorschau-Eintr\U00E4ge: %lld";
"label.errors_count" = "Fehler: %lld";
"label.warnings_count" = "Warnungen: %lld";
"label.mood_type_scale" = "Stimmungstyp %lld";
"button.load_json" = "JSON-Datei laden";
"button.load_sample" = "Beispieldatei laden";
"button.run_dry_run" = "Testlauf starten";
"button.commit" = "In Apple Health importieren";
"button.ok" = "OK";
"error.title" = "Importfehler";
"error.healthkit.permission" = "HealthKit-Berechtigung wurde nicht erteilt.";
"error.healthkit.save" = "Apple Health hat das Speichern abgelehnt.";
"error.sample.missing" = "Die eingebettete Moodwell-Beispieldatei wurde nicht gefunden.";
"error.load.file" = "Die ausgew\U00E4hlte JSON-Datei konnte nicht gelesen werden.";
"error.dryrun.required" = "Bitte zuerst einen Testlauf ausf\U00FChren.";
"error.dryrun.fix" = "Bitte zuerst alle Testlauf-Fehler beheben.";
"error.import.failed" = "Import fehlgeschlagen.";
"status.loaded" = "%lld Eintr\U00E4ge geladen.";
"status.dryrun.success" = "Testlauf hat %lld Apple-Health-Eintr\U00E4ge erzeugt.";
"status.dryrun.withErrors" = "Testlauf hat %lld blockierende Fehler gefunden.";
"status.import.success" = "%lld Eintr\U00E4ge nach Apple Health importiert.";
"text.commit_notice" = "Die App fordert nur Schreibrechte an und schreibt ausschlie\U00DFlich Gem\U00FCtszustand-Daten.";
"field.created_at" = "Datum";
"field.created_at.description" = "Zeitstempel f\U00FCr den Health-Eintrag.";
"field.mood_type" = "Stimmungstyp";
"field.mood_type.description" = "Moodwell-Wert, der auf die Health-Valenz (-1 bis +1) abgebildet wird.";
"field.good_emotions" = "Positive Emotionen";
"field.good_emotions.description" = "Wird auf Health-Labels abgebildet.";
"field.bad_emotions" = "Negative Emotionen";
"field.bad_emotions.description" = "Wird auf Health-Labels abgebildet.";
"field.activities" = "Aktivit\U00E4ten";
"field.activities.description" = "Wird auf Health-Zuordnungen abgebildet.";
"field.weather" = "Wetter";
"field.weather.description" = "Wird auf Wetter-Zuordnung und Metadaten abgebildet.";
"field.notes" = "Notizen";
"field.notes.description" = "Optionale Quellnotiz, wird als Metadaten gespeichert.";
"field.external_id" = "Externe ID";
"field.external_id.description" = "Quell-ID f\U00FCr Nachverfolgbarkeit.";
"text.mapping.unavailable" = "Lade zuerst eine JSON-Datei, bevor du die Feldzuordnung bearbeitest.";
"error.healthkit.unsupported" = "Der Gemütszustand-Import erfordert iOS 18 oder neuer.";

View File

@@ -0,0 +1,53 @@
"screen.title.import" = "Bulk Health Import";
"template.title.state_of_mind" = "State of Mind";
"health.segment.state_of_mind" = "State of Mind (Gem\U00FCtszustand)";
"section.template" = "Apple Health Template";
"section.source" = "Source Data";
"section.mapping" = "Field Mapping";
"section.scale" = "Mood Type Scale";
"section.dry_run" = "Dry Run";
"section.commit" = "Commit Import";
"label.health_segment" = "Health Segment";
"label.template" = "Template";
"label.records_loaded" = "Records loaded: %lld";
"label.preview_count" = "Preview records: %lld";
"label.errors_count" = "Errors: %lld";
"label.warnings_count" = "Warnings: %lld";
"label.mood_type_scale" = "Mood type %lld";
"button.load_json" = "Load JSON File";
"button.load_sample" = "Load Bundled Sample";
"button.run_dry_run" = "Run Dry Run";
"button.commit" = "Commit to Apple Health";
"button.ok" = "OK";
"error.title" = "Import Error";
"error.healthkit.permission" = "HealthKit authorization was not granted.";
"error.healthkit.save" = "Apple Health rejected the save operation.";
"error.sample.missing" = "Bundled Moodwell sample was not found.";
"error.load.file" = "Could not read the selected JSON file.";
"error.dryrun.required" = "Run a dry run before committing the import.";
"error.dryrun.fix" = "Fix dry run errors before commit.";
"error.import.failed" = "Import failed.";
"status.loaded" = "Loaded %lld records.";
"status.dryrun.success" = "Dry run created %lld Apple Health records.";
"status.dryrun.withErrors" = "Dry run found %lld blocking issues.";
"status.import.success" = "Imported %lld records to Apple Health.";
"text.commit_notice" = "The app asks for write permission and only writes State of Mind samples.";
"field.created_at" = "Date";
"field.created_at.description" = "Timestamp used for the Health record.";
"field.mood_type" = "Mood Type";
"field.mood_type.description" = "Moodwell score that maps to Health valence (-1 to +1).";
"field.good_emotions" = "Positive Emotions";
"field.good_emotions.description" = "Mapped to Health labels.";
"field.bad_emotions" = "Negative Emotions";
"field.bad_emotions.description" = "Mapped to Health labels.";
"field.activities" = "Activities";
"field.activities.description" = "Mapped to Health associations.";
"field.weather" = "Weather";
"field.weather.description" = "Mapped to Weather association and metadata.";
"field.notes" = "Notes";
"field.notes.description" = "Optional source note; kept in metadata.";
"field.external_id" = "External ID";
"field.external_id.description" = "Source identifier for traceability.";
"text.mapping.unavailable" = "Load a JSON file before editing field mapping.";
"error.healthkit.unsupported" = "State of Mind import requires iOS 18 or newer.";

View File

@@ -0,0 +1,53 @@
"screen.title.import" = "Importaci\U00F3n masiva de Salud";
"template.title.state_of_mind" = "Estado de \U00E1nimo";
"health.segment.state_of_mind" = "Estado de \U00E1nimo";
"section.template" = "Plantilla de Apple Health";
"section.source" = "Datos de origen";
"section.mapping" = "Asignaci\U00F3n de campos";
"section.scale" = "Escala de tipo de \U00E1nimo";
"section.dry_run" = "Prueba";
"section.commit" = "Confirmar importaci\U00F3n";
"label.health_segment" = "Segmento de Health";
"label.template" = "Plantilla";
"label.records_loaded" = "Registros cargados: %lld";
"label.preview_count" = "Registros de vista previa: %lld";
"label.errors_count" = "Errores: %lld";
"label.warnings_count" = "Advertencias: %lld";
"label.mood_type_scale" = "Tipo de \U00E1nimo %lld";
"button.load_json" = "Cargar archivo JSON";
"button.load_sample" = "Cargar ejemplo incluido";
"button.run_dry_run" = "Ejecutar prueba";
"button.commit" = "Importar a Apple Health";
"button.ok" = "Aceptar";
"error.title" = "Error de importaci\U00F3n";
"error.healthkit.permission" = "No se concedi\U00F3 autorizaci\U00F3n de HealthKit.";
"error.healthkit.save" = "Apple Health rechaz\U00F3 el guardado.";
"error.sample.missing" = "No se encontr\U00F3 el ejemplo integrado de Moodwell.";
"error.load.file" = "No se pudo leer el archivo JSON seleccionado.";
"error.dryrun.required" = "Ejecuta una prueba antes de confirmar la importaci\U00F3n.";
"error.dryrun.fix" = "Corrige los errores de la prueba antes de continuar.";
"error.import.failed" = "La importaci\U00F3n fall\U00F3.";
"status.loaded" = "Se cargaron %lld registros.";
"status.dryrun.success" = "La prueba cre\U00F3 %lld registros de Apple Health.";
"status.dryrun.withErrors" = "La prueba encontr\U00F3 %lld errores bloqueantes.";
"status.import.success" = "Se importaron %lld registros a Apple Health.";
"text.commit_notice" = "La app solicita solo permiso de escritura y solo escribe datos de estado de \U00E1nimo.";
"field.created_at" = "Fecha";
"field.created_at.description" = "Marca de tiempo usada para el registro de Health.";
"field.mood_type" = "Tipo de \U00E1nimo";
"field.mood_type.description" = "Valor de Moodwell que se mapea a la valencia de Health (-1 a +1).";
"field.good_emotions" = "Emociones positivas";
"field.good_emotions.description" = "Se mapea a etiquetas de Health.";
"field.bad_emotions" = "Emociones negativas";
"field.bad_emotions.description" = "Se mapea a etiquetas de Health.";
"field.activities" = "Actividades";
"field.activities.description" = "Se mapea a asociaciones de Health.";
"field.weather" = "Clima";
"field.weather.description" = "Se mapea a asociaci\U00F3n de clima y metadatos.";
"field.notes" = "Notas";
"field.notes.description" = "Nota opcional de origen, se conserva en metadatos.";
"field.external_id" = "ID externa";
"field.external_id.description" = "Identificador de origen para trazabilidad.";
"text.mapping.unavailable" = "Carga primero un archivo JSON antes de editar la asignación de campos.";
"error.healthkit.unsupported" = "La importación de estado de ánimo requiere iOS 18 o posterior.";

View File

@@ -0,0 +1,53 @@
"screen.title.import" = "Import massif Sant\U00E9";
"template.title.state_of_mind" = "\U00C9tat d'esprit";
"health.segment.state_of_mind" = "\U00C9tat d'esprit";
"section.template" = "Mod\U00E8le Apple Health";
"section.source" = "Donn\U00E9es source";
"section.mapping" = "Correspondance des champs";
"section.scale" = "\U00C9chelle du type d'humeur";
"section.dry_run" = "Simulation";
"section.commit" = "Confirmer l'import";
"label.health_segment" = "Segment Health";
"label.template" = "Mod\U00E8le";
"label.records_loaded" = "Enregistrements charg\U00E9s : %lld";
"label.preview_count" = "Enregistrements pr\U00E9visualis\U00E9s : %lld";
"label.errors_count" = "Erreurs : %lld";
"label.warnings_count" = "Avertissements : %lld";
"label.mood_type_scale" = "Type d'humeur %lld";
"button.load_json" = "Charger un fichier JSON";
"button.load_sample" = "Charger l'exemple int\U00E9gr\U00E9";
"button.run_dry_run" = "Lancer la simulation";
"button.commit" = "Importer dans Apple Health";
"button.ok" = "OK";
"error.title" = "Erreur d'import";
"error.healthkit.permission" = "L'autorisation HealthKit n'a pas \U00E9t\U00E9 accord\U00E9e.";
"error.healthkit.save" = "Apple Health a refus\U00E9 l'enregistrement.";
"error.sample.missing" = "L'exemple Moodwell int\U00E9gr\U00E9 est introuvable.";
"error.load.file" = "Impossible de lire le fichier JSON s\U00E9lectionn\U00E9.";
"error.dryrun.required" = "Ex\U00E9cutez une simulation avant de confirmer l'import.";
"error.dryrun.fix" = "Corrigez les erreurs de simulation avant de continuer.";
"error.import.failed" = "\U00C9chec de l'import.";
"status.loaded" = "%lld enregistrements charg\U00E9s.";
"status.dryrun.success" = "La simulation a cr\U00E9\U00E9 %lld enregistrements Apple Health.";
"status.dryrun.withErrors" = "La simulation a trouv\U00E9 %lld erreurs bloquantes.";
"status.import.success" = "%lld enregistrements import\U00E9s dans Apple Health.";
"text.commit_notice" = "L'app demande uniquement l'autorisation d'\U00E9criture et n'\U00E9crit que des donn\U00E9es d'\U00E9tat d'esprit.";
"field.created_at" = "Date";
"field.created_at.description" = "Horodatage utilis\U00E9 pour l'enregistrement Health.";
"field.mood_type" = "Type d'humeur";
"field.mood_type.description" = "Valeur Moodwell mapp\U00E9e vers la valence Health (-1 \U00E0 +1).";
"field.good_emotions" = "\U00C9motions positives";
"field.good_emotions.description" = "Mapp\U00E9es vers les \U00E9tiquettes Health.";
"field.bad_emotions" = "\U00C9motions n\U00E9gatives";
"field.bad_emotions.description" = "Mapp\U00E9es vers les \U00E9tiquettes Health.";
"field.activities" = "Activit\U00E9s";
"field.activities.description" = "Mapp\U00E9es vers les associations Health.";
"field.weather" = "M\U00E9t\U00E9o";
"field.weather.description" = "Mapp\U00E9e vers l'association m\U00E9t\U00E9o et les m\U00E9tadonn\U00E9es.";
"field.notes" = "Notes";
"field.notes.description" = "Note source facultative, conserv\U00E9e en m\U00E9tadonn\U00E9es.";
"field.external_id" = "ID externe";
"field.external_id.description" = "Identifiant source pour la tra\U00E7abilit\U00E9.";
"text.mapping.unavailable" = "Chargez d'abord un fichier JSON avant de modifier la correspondance des champs.";
"error.healthkit.unsupported" = "L'import d'état d'esprit nécessite iOS 18 ou version ultérieure.";

View File

@@ -0,0 +1,59 @@
import Foundation
import HealthKit
@MainActor
final class HealthKitService {
private let healthStore = HKHealthStore()
func requestWriteAccessForStateOfMind() async throws {
guard #available(iOS 18.0, *) else {
throw NSError(domain: "BulkHealth", code: 3, userInfo: [NSLocalizedDescriptionKey: String(localized: "error.healthkit.unsupported")])
}
let shareTypes: Set<HKSampleType> = [HKObjectType.stateOfMindType()]
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
healthStore.requestAuthorization(toShare: shareTypes, read: []) { success, error in
if let error {
continuation.resume(throwing: error)
return
}
if success {
continuation.resume(returning: ())
} else {
continuation.resume(throwing: NSError(domain: "BulkHealth", code: 1, userInfo: [NSLocalizedDescriptionKey: String(localized: "error.healthkit.permission")]))
}
}
}
}
func save(_ drafts: [StateOfMindDraft]) async throws {
guard #available(iOS 18.0, *) else {
throw NSError(domain: "BulkHealth", code: 4, userInfo: [NSLocalizedDescriptionKey: String(localized: "error.healthkit.unsupported")])
}
let samples = drafts.map { draft in
HKStateOfMind(
date: draft.date,
kind: draft.kind,
valence: draft.valence,
labels: draft.labels,
associations: draft.associations,
metadata: draft.metadata
)
}
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
healthStore.save(samples) { success, error in
if let error {
continuation.resume(throwing: error)
return
}
if success {
continuation.resume(returning: ())
} else {
continuation.resume(throwing: NSError(domain: "BulkHealth", code: 2, userInfo: [NSLocalizedDescriptionKey: String(localized: "error.healthkit.save")]))
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
import Testing
@testable import BulkHealth
struct BulkHealthTests {
@Test
func moodwellToStateOfMindTransform() throws {
let entry = MoodwellEntry(
arrayOfPhotos: [],
moodType: 4,
moodUniqueIdentifier: "abc-123",
arrayOfBadEmotions: ["Frustrated"],
notesString: "stressful but okay",
arrayOfWeathers: ["Sunny"],
createdAt: "2023-01-01T10:00:00.000Z",
arrayOfActivities: ["Work", "Sports"],
arrayOfGoodEmotions: ["Happy", "Optimistic"]
)
let result = MoodwellStateOfMindTemplate.makeDrafts(
entries: [entry],
mapping: MoodwellStateOfMindTemplate.defaultMapping,
moodValenceScale: [1: -0.8, 2: -0.25, 3: 0.3, 4: 0.8]
)
#expect(result.errors.isEmpty)
#expect(result.drafts.count == 1)
let draft = try #require(result.drafts.first)
#expect(draft.valence == 0.8)
#expect(draft.labels.contains(.happy))
#expect(draft.labels.contains(.hopeful))
#expect(draft.labels.contains(.frustrated))
#expect(draft.associations.contains(.work))
#expect(draft.associations.contains(.fitness))
#expect(draft.associations.contains(.weather))
}
@Test
func invalidDateCreatesError() {
let entry = MoodwellEntry(
arrayOfPhotos: [],
moodType: 2,
moodUniqueIdentifier: "broken-date",
arrayOfBadEmotions: [],
notesString: nil,
arrayOfWeathers: [],
createdAt: "not-a-date",
arrayOfActivities: [],
arrayOfGoodEmotions: []
)
let result = MoodwellStateOfMindTemplate.makeDrafts(
entries: [entry],
mapping: MoodwellStateOfMindTemplate.defaultMapping,
moodValenceScale: [1: -0.8, 2: -0.25, 3: 0.3, 4: 0.8]
)
#expect(result.drafts.isEmpty)
#expect(!result.errors.isEmpty)
}
}

6
LEARNINGS.md Normal file
View File

@@ -0,0 +1,6 @@
# Learnings
- `HKStateOfMind` write APIs are directly available on iOS 18+ and can be created with date/kind/valence/labels/associations in one call.
- Moodwell exports can include large arrays and sparse optional fields, so dry-run validation is necessary before committing to HealthKit.
- Headless/sandboxed `xcodebuild` may fail SwiftUI `#Preview` macro expansion, so keeping previews out of CI-oriented builds avoids false negatives.
- In `.strings` files, `%@` with integer arguments in `String(format:)` can trigger `EXC_BAD_ACCESS`; use numeric specifiers like `%ld` and `String.localizedStringWithFormat`.

53
project.yml Normal file
View File

@@ -0,0 +1,53 @@
name: BulkHealth
options:
deploymentTarget:
iOS: 26.0
settings:
base:
DEVELOPMENT_TEAM: NG5W75WE8U
PRODUCT_BUNDLE_IDENTIFIER: de.felixfoertsch.bulkhealth
SWIFT_VERSION: 6.0
MARKETING_VERSION: 2026.02.18
CURRENT_PROJECT_VERSION: 1
CODE_SIGN_STYLE: Automatic
packages: {}
targets:
BulkHealth:
type: application
platform: iOS
sources:
- BulkHealthApp
info:
path: BulkHealthApp/Info.plist
properties:
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
NSHealthShareUsageDescription: This app reads and previews your selected mental state records for import review.
NSHealthUpdateUsageDescription: This app writes imported mental state records to Apple Health after you confirm.
settings:
base:
TARGETED_DEVICE_FAMILY: 1
INFOPLIST_KEY_CFBundleDisplayName: BulkHealth
ENABLE_PREVIEWS: YES
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
CODE_SIGN_ENTITLEMENTS: BulkHealthApp/BulkHealth.entitlements
entitlements:
path: BulkHealthApp/BulkHealth.entitlements
properties:
com.apple.developer.healthkit: true
dependencies: []
scheme:
testTargets:
- BulkHealthTests
BulkHealthTests:
type: bundle.unit-test
platform: iOS
sources:
- BulkHealthTests
settings:
base:
GENERATE_INFOPLIST_FILE: YES
dependencies:
- target: BulkHealth