bootstrap bulkhealth moodwell state-of-mind importer mvp
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.DS_Store
|
||||
.derivedData/
|
||||
1
230613-MoodWell Data.json
Normal file
1
230613-MoodWell Data.json
Normal file
File diff suppressed because one or more lines are too long
17
AI_AGENT_REPORT.md
Normal file
17
AI_AGENT_REPORT.md
Normal 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.
|
||||
490
BulkHealth.xcodeproj/project.pbxproj
Normal file
490
BulkHealth.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
7
BulkHealth.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
BulkHealth.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
Binary file not shown.
106
BulkHealth.xcodeproj/xcshareddata/xcschemes/BulkHealth.xcscheme
Normal file
106
BulkHealth.xcodeproj/xcshareddata/xcschemes/BulkHealth.xcscheme
Normal 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>
|
||||
8
BulkHealthApp/BulkHealth.entitlements
Normal file
8
BulkHealthApp/BulkHealth.entitlements
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.healthkit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
12
BulkHealthApp/BulkHealthApp.swift
Normal file
12
BulkHealthApp/BulkHealthApp.swift
Normal file
@@ -0,0 +1,12 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BulkHealthApp: App {
|
||||
@StateObject private var viewModel = ImportFlowViewModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView(viewModel: viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
188
BulkHealthApp/ContentView.swift
Normal file
188
BulkHealthApp/ContentView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
95
BulkHealthApp/Import/ImportFlowViewModel.swift
Normal file
95
BulkHealthApp/Import/ImportFlowViewModel.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
111
BulkHealthApp/Import/ImportModels.swift
Normal file
111
BulkHealthApp/Import/ImportModels.swift
Normal 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]
|
||||
}
|
||||
183
BulkHealthApp/Import/MoodwellStateOfMindTemplate.swift
Normal file
183
BulkHealthApp/Import/MoodwellStateOfMindTemplate.swift
Normal 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
33
BulkHealthApp/Info.plist
Normal 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>
|
||||
1
BulkHealthApp/Resources/MoodwellData.json
Normal file
1
BulkHealthApp/Resources/MoodwellData.json
Normal file
File diff suppressed because one or more lines are too long
53
BulkHealthApp/Resources/de.lproj/Localizable.strings
Normal file
53
BulkHealthApp/Resources/de.lproj/Localizable.strings
Normal 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.";
|
||||
53
BulkHealthApp/Resources/en.lproj/Localizable.strings
Normal file
53
BulkHealthApp/Resources/en.lproj/Localizable.strings
Normal 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.";
|
||||
53
BulkHealthApp/Resources/es.lproj/Localizable.strings
Normal file
53
BulkHealthApp/Resources/es.lproj/Localizable.strings
Normal 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.";
|
||||
53
BulkHealthApp/Resources/fr.lproj/Localizable.strings
Normal file
53
BulkHealthApp/Resources/fr.lproj/Localizable.strings
Normal 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.";
|
||||
59
BulkHealthApp/Services/HealthKitService.swift
Normal file
59
BulkHealthApp/Services/HealthKitService.swift
Normal 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")]))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
BulkHealthTests/BulkHealthTests.swift
Normal file
58
BulkHealthTests/BulkHealthTests.swift
Normal 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
6
LEARNINGS.md
Normal 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
53
project.yml
Normal 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
|
||||
Reference in New Issue
Block a user