Update signing configuration to use 88oakapps.feels identifiers

- Update App Group IDs from group.com.tt.feels to group.com.88oakapps.feels
- Update iCloud container IDs from iCloud.com.tt.feels to iCloud.com.88oakapps.feels
- Sync code constants with entitlements across all targets (iOS, Watch, Widget)
- Update documentation in CLAUDE.md and PROJECT_OVERVIEW.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-29 10:01:49 -06:00
parent b6290403b0
commit 810ac2d649
28 changed files with 12289 additions and 13332 deletions

View File

@@ -34,7 +34,10 @@
"Bash(unzip:*)",
"Bash(plutil:*)",
"Bash(done)",
"Bash(for:*)"
"Bash(for:*)",
"Bash(# Check Button and Label strings grep -rE ''\\(Button|Label|navigationTitle\\)\\\\\\(\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
"Bash(# Double-check a few of these strings to make sure they''re not used echo \"\"=== Checking ''Custom'' ===\"\" grep -rn ''\"\"Custom\"\"'' /Users/treyt/Desktop/code/Feels/Shared/ --include=\"\"*.swift\"\")",
"Bash(echo \"=== How ''3D card flip'' is used ===\" grep -rn \"3D card flip\" /Users/treyt/Desktop/code/Feels/Shared/ --include=\"*.swift\")"
],
"ask": [
"Bash(git commit:*)",

View File

@@ -30,8 +30,8 @@ Core Data operations are split across files in `Shared/Persistence/`:
## App Groups
- **Production**: `group.com.88oakapps.ifeel`
- **Debug**: `group.com.88oakapps.ifeelDebug`
- **Production**: `group.com.88oakapps.feels`
- **Debug**: `group.com.88oakapps.feels.debug`
## Build & Run

View File

@@ -2,9 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
<string>iCloud.com.88oakapps.feels</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
@@ -12,13 +20,7 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.feelsDebug</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array>
<string>health-records</string>
<string>group.com.88oakapps.feels</string>
</array>
</dict>
</plist>

View File

@@ -4,9 +4,13 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
<string>iCloud.com.88oakapps.feels.debug</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
@@ -14,11 +18,7 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.feelsDebug</string>
<string>group.com.88oakapps.feels.debug</string>
</array>
<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>
</dict>
</plist>

View File

@@ -2,17 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>group.com.tt.feels</string>
<string>iCloud.com.88oakapps.feels</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<key>com.apple.security.application-groups</key>
<array>
<string>iCloud.com.tt.feels</string>
<string>group.com.88oakapps.feels</string>
</array>
</dict>
</plist>

View File

@@ -2,17 +2,19 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>group.com.tt.feelsDebug</string>
<string>iCloud.com.88oakapps.feels.debug</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
<string>CloudKit</string>
</array>
<key>com.apple.developer.icloud-container-identifiers</key>
<key>com.apple.security.application-groups</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
<string>group.com.88oakapps.feels.debug</string>
</array>
</dict>
</plist>

View File

@@ -23,17 +23,5 @@
<string>processing</string>
<string>remote-notification</string>
</array>
<key>NSHealthShareUsageDescription</key>
<string>Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>Feels syncs your mood data to Apple Health so you can see how your emotions correlate with sleep, exercise, and other health metrics.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Feels uses the camera to take photos for your mood journal entries.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Feels accesses your photo library to attach photos to your mood journal entries.</string>
<key>NSFaceIDUsageDescription</key>
<string>Feels uses Face ID to protect your private mood data.</string>
</dict>
</plist>

View File

@@ -578,6 +578,7 @@
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch AppDebug.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Feels;
@@ -589,7 +590,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug.watchkitapp;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.watch.debug;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;
@@ -733,10 +734,17 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Feels--iOS--Info.plist";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "Feels uses the camera to take photos for your mood journal entries.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Feels uses Face ID to protect your private mood data.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Feels syncs your mood data to Apple Health so you can see how your emotions correlate with sleep, exercise, and other health metrics.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Feels accesses your photo library to attach photos to your mood journal entries.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -748,7 +756,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.debug;
PRODUCT_NAME = Feels;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
@@ -768,10 +776,17 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Feels--iOS--Info.plist";
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_NSCameraUsageDescription = "Feels uses the camera to take photos for your mood journal entries.";
INFOPLIST_KEY_NSFaceIDUsageDescription = "Feels uses Face ID to protect your private mood data.";
INFOPLIST_KEY_NSHealthShareUsageDescription = "Feels uses your health data to find correlations between your activity, sleep, and mood patterns to provide personalized insights.";
INFOPLIST_KEY_NSHealthUpdateUsageDescription = "Feels syncs your mood data to Apple Health so you can see how your emotions correlate with sleep, exercise, and other health metrics.";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Feels accesses your photo library to attach photos to your mood journal entries.";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
@@ -783,7 +798,7 @@
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels;
PRODUCT_NAME = Feels;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
@@ -938,10 +953,12 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "FeelsWidgetExtension-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = FeelsWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -949,7 +966,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug.FeelsWidgetDebug;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.widget.debug;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
@@ -970,10 +987,12 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "FeelsWidgetExtension-Info.plist";
INFOPLIST_KEY_CFBundleDisplayName = FeelsWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSSupportsLiveActivities = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -981,7 +1000,7 @@
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feelsDebug.FeelsWidget;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = iphoneos;
@@ -1002,18 +1021,19 @@
CODE_SIGN_ENTITLEMENTS = "Feels Watch App/Feels Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 23;
DEVELOPMENT_TEAM = QND55P4443;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Feels;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown";
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feels;
INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.tt.feelsDebug;
INFOPLIST_KEY_WKRunsIndependentlyOfCompanionApp = NO;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
PRODUCT_BUNDLE_IDENTIFIER = com.tt.feels.watchkitapp;
PRODUCT_BUNDLE_IDENTIFIER = com.88oakapps.feels.watch;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = watchos;
SKIP_INSTALL = YES;

View File

@@ -12,12 +12,12 @@
<key>Feels (macOS).xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>3</integer>
</dict>
<key>Feels Watch App.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
<integer>2</integer>
</dict>
<key>FeelsWidgetExtension.xcscheme_^#shared#^_</key>
<dict>

File diff suppressed because it is too large Load Diff

View File

@@ -32,46 +32,49 @@ struct VotingView: View {
}
}
// MARK: - Small Widget: 3 over 2 grid (no text - just mood buttons)
// MARK: - Small Widget: 3 over 2 grid centered in 50%|50% vertical split
private var smallLayout: some View {
VStack(spacing: 8) {
// Top row: Great, Good, Average
VStack(spacing: 0) {
// Top half: Great, Good, Average
HStack(spacing: 12) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodButton(for: mood, size: 40)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
// Bottom row: Bad, Horrible
// Bottom half: Bad, Horrible
HStack(spacing: 12) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood, size: 40)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
// MARK: - Medium Widget: Single row
// MARK: - Medium Widget: Vertical split - text top, voting bottom
private var mediumLayout: some View {
VStack(spacing: 12) {
VStack(spacing: 0) {
// Top: Text left-aligned, centered horizontally
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.headline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.multilineTextAlignment(.leading)
.lineLimit(2)
.minimumScaleFactor(0.8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.padding(.horizontal, 16)
// Bottom: Voting buttons with equal spacing, centered
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButtonMedium(for: mood)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.padding(.horizontal, 12)
.padding(.vertical, 16)
}
@ViewBuilder
@@ -146,29 +149,40 @@ struct LargeVotingView: View {
}
var body: some View {
VStack(spacing: 16) {
Spacer()
GeometryReader { geo in
VStack(spacing: 0) {
// Top 25%: Title centered x,y
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.frame(height: geo.size.height * 0.25)
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 8)
// Bottom 75%: Voting buttons in two rows
VStack(spacing: 0) {
// Top row at 33%: Great, Good, Average
HStack(spacing: 16) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Large mood buttons in a row - flexible spacing
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood)
.frame(maxWidth: .infinity)
// Bottom row at 66%: Bad, Horrible
HStack(spacing: 16) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(height: geo.size.height * 0.75)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.vertical, 16)
}
@ViewBuilder

View File

@@ -7,7 +7,5 @@
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -2,9 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
<string>iCloud.com.88oakapps.feels</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
@@ -12,7 +14,7 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.feelsDebug</string>
<string>group.com.88oakapps.feels</string>
</array>
</dict>
</plist>

View File

@@ -6,7 +6,7 @@
<string>development</string>
<key>com.apple.developer.icloud-container-identifiers</key>
<array>
<string>iCloud.com.tt.feelsDebug</string>
<string>iCloud.com.88oakapps.feels.debug</string>
</array>
<key>com.apple.developer.icloud-services</key>
<array>
@@ -14,7 +14,7 @@
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.tt.feelsDebug</string>
<string>group.com.88oakapps.feels.debug</string>
</array>
</dict>
</plist>

View File

@@ -137,7 +137,7 @@ Three widget types in `FeelsWidget/`:
2. **FeelsGraphicWidget**: Small widget with mood graphic
3. **FeelsIconWidget**: Custom icon widget
Data shared via App Groups: `group.com.88oakapps.ifeel`
Data shared via App Groups: `group.com.88oakapps.feels`
---
@@ -155,8 +155,8 @@ Data shared via App Groups: `group.com.88oakapps.ifeel`
## Configuration
### App Groups
- Production: `group.com.88oakapps.ifeel`
- Debug: `group.com.88oakapps.ifeelDebug`
- Production: `group.com.88oakapps.feels`
- Debug: `group.com.88oakapps.feels.debug`
### Background Tasks
- Identifier: `com.88oak.Feels.dbUpdateMissing`

View File

@@ -241,29 +241,40 @@ struct FeelsTipModifier: ViewModifier {
let tip: any FeelsTip
let gradientColors: [Color]
@ObservedObject private var tipsManager = FeelsTipsManager.shared
// Use local state for sheet to avoid interference from other manager state changes
@State private var showSheet = false
@State private var hasCheckedEligibility = false
func body(content: Content) -> some View {
content
.onAppear {
tipsManager.showTipIfEligible(tip)
}
.sheet(isPresented: $tipsManager.showTipModal) {
if let currentTip = tipsManager.currentTip {
TipModalView(
icon: currentTip.icon,
title: currentTip.title,
message: currentTip.message,
gradientColors: gradientColors,
onDismiss: {
tipsManager.markTipAsShown(currentTip)
}
)
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
.presentationCornerRadius(28)
// Only check eligibility once per view lifetime
guard !hasCheckedEligibility else { return }
hasCheckedEligibility = true
// Delay tip presentation to ensure view hierarchy is fully established
// This prevents "presenting from detached view controller" errors
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if FeelsTipsManager.shared.shouldShowTip(tip) {
showSheet = true
}
}
}
.sheet(isPresented: $showSheet) {
TipModalView(
icon: tip.icon,
title: tip.title,
message: tip.message,
gradientColors: gradientColors,
onDismiss: {
showSheet = false
FeelsTipsManager.shared.markTipAsShown(tip)
}
)
.presentationDetents([.height(340)])
.presentationDragIndicator(.visible)
.presentationCornerRadius(28)
}
}
}

View File

@@ -261,6 +261,60 @@ class HealthKitManager: ObservableObject {
])
}
// MARK: - Delete All Moods from HealthKit
/// Deletes all State of Mind samples created by this app
/// Note: HealthKit only allows deleting samples that your app created
func deleteAllMoods() async throws -> Int {
guard isHealthKitAvailable else {
throw HealthKitError.notAvailable
}
guard let stateOfMindType = stateOfMindType else {
throw HealthKitError.typeNotAvailable
}
guard checkAuthorizationStatus() == .sharingAuthorized else {
throw HealthKitError.notAuthorized
}
logger.info("Starting deletion of all State of Mind samples from this app")
// Fetch all State of Mind samples (HealthKit will only return ones we can delete - our own)
let samples = try await fetchMoods(
from: Date(timeIntervalSince1970: 0),
to: Date().addingTimeInterval(86400) // Include today + 1 day buffer
)
guard !samples.isEmpty else {
logger.info("No State of Mind samples found to delete")
return 0
}
logger.info("Found \(samples.count) State of Mind samples to delete")
// Delete in batches
let batchSize = 50
var deletedCount = 0
for batchStart in stride(from: 0, to: samples.count, by: batchSize) {
let batchEnd = min(batchStart + batchSize, samples.count)
let batch = Array(samples[batchStart..<batchEnd])
do {
try await healthStore.delete(batch)
deletedCount += batch.count
logger.info("Deleted batch \(batchStart/batchSize + 1): \(batch.count) samples")
} catch {
logger.error("Failed to delete batch starting at \(batchStart): \(error.localizedDescription)")
throw error
}
}
logger.info("Successfully deleted \(deletedCount) State of Mind samples from HealthKit")
return deletedCount
}
// MARK: - Read Mood from HealthKit
func fetchMoods(from startDate: Date, to endDate: Date) async throws -> [HKStateOfMind] {

View File

@@ -65,7 +65,7 @@ enum AppTheme: Int, CaseIterable, Identifiable {
var description: String {
switch self {
case .zenGarden:
return "Japanese minimalism meets mindful awareness. Soft pastels, organic growth icons, brush-stroke entries, and contemplative vertical voting."
return "Japanese minimalism meets mindful awareness. Soft pastels, organic growth icons, clean entries, and contemplative vertical voting."
case .synthwave:
return "80s arcade aesthetic with neon glow. Electric colors, cosmic icons, grid backgrounds, and equalizer-bar voting."
case .celestial:
@@ -75,7 +75,7 @@ enum AppTheme: Int, CaseIterable, Identifiable {
case .mixtape:
return "Cassette culture and analog warmth. Tape reels, track numbers, and the tactile feel of pressing play."
case .bloom:
return "From wilted flower to full bloom. Organic shapes, glowing orbs, and the gentle metaphor of growth."
return "From wilted flower to full bloom. Atmospheric glowing entries, organic icons, and the gentle metaphor of growth."
case .heartfelt:
return "Unashamed emotional expression. Heart icons from broken to sparkling, bold colors, intuitive selection."
case .minimal:
@@ -83,7 +83,7 @@ enum AppTheme: Int, CaseIterable, Identifiable {
case .luxe:
return "Liquid glass and premium materials. Cutting-edge iOS design language for the discerning user."
case .forecast:
return "Your mood is the weather. Storm to sunshine icons, flowing wave entries, and natural intuition."
return "Your mood is the weather. Storm to sunshine icons, colorful bubble entries, and natural intuition."
case .playful:
return "Life's too short to be serious. Vibrant neons, familiar emoji, and game-like interaction."
case .journal:
@@ -146,16 +146,16 @@ enum AppTheme: Int, CaseIterable, Identifiable {
var entryStyle: DayViewStyle {
switch self {
case .zenGarden: return .ink
case .zenGarden: return .minimal
case .synthwave: return .neon
case .celestial: return .orbit
case .editorial: return .chronicle
case .mixtape: return .tape
case .bloom: return .morph
case .bloom: return .aura
case .heartfelt: return .bubble
case .minimal: return .minimal
case .luxe: return .glass
case .forecast: return .wave
case .forecast: return .bubble
case .playful: return .pattern
case .journal: return .stack
}
@@ -169,10 +169,10 @@ enum AppTheme: Int, CaseIterable, Identifiable {
case .editorial: return .horizontal
case .mixtape: return .cards
case .bloom: return .aura
case .heartfelt: return .radial
case .heartfelt: return .horizontal
case .minimal: return .horizontal
case .luxe: return .aura
case .forecast: return .radial
case .forecast: return .horizontal
case .playful: return .cards
case .journal: return .stacked
}

View File

@@ -9,23 +9,20 @@ import SwiftUI
class DaysFilterClass: ObservableObject {
static let shared = DaysFilterClass()
@Published public var currentFilters = [Int]()
// Always show all days (1-7 = Sunday through Saturday)
@Published public var currentFilters = [1, 2, 3, 4, 5, 6, 7]
init() {
let storedDays = UserDefaultsStore.getDaysFilter()
currentFilters = storedDays
// Always include all days
currentFilters = [1, 2, 3, 4, 5, 6, 7]
}
func addFilter(newFilter: Int) {
currentFilters.append(newFilter)
currentFilters = UserDefaultsStore.saveDaysFilter(days: currentFilters)
// No-op: always show all days
}
func removeFilter(filter: Int) {
if let index = currentFilters.firstIndex(of: filter) {
currentFilters.remove(at: index)
}
currentFilters = UserDefaultsStore.saveDaysFilter(days: currentFilters)
// No-op: always show all days
}
}

View File

@@ -10,17 +10,15 @@ import Foundation
enum VotingLayoutStyle: Int, CaseIterable {
case horizontal = 0 // Current: 5 buttons in a row
case cards = 1 // Larger tappable cards with labels
case radial = 2 // Semi-circle/wheel arrangement
case stacked = 3 // Full-width vertical list
case aura = 4 // Atmospheric glowing orbs with flowing layout
case orbit = 5 // Celestial orbit with center core
case neon = 6 // Synthwave arcade equalizer with glowing segments
case stacked = 2 // Full-width vertical list
case aura = 3 // Atmospheric glowing orbs with flowing layout
case orbit = 4 // Celestial orbit with center core
case neon = 5 // Synthwave arcade equalizer with glowing segments
var displayName: String {
switch self {
case .horizontal: return "Horizontal"
case .cards: return "Cards"
case .radial: return "Radial"
case .stacked: return "Stacked"
case .aura: return "Aura"
case .orbit: return "Orbit"
@@ -162,6 +160,21 @@ enum DayViewStyle: Int, CaseIterable {
var isGridLayout: Bool {
self == .grid
}
/// Styles available in the picker (some are disabled/experimental)
var isAvailable: Bool {
switch self {
case .motion, .leather, .wave, .morph, .prism, .ink:
return false
default:
return true
}
}
/// All styles available to users
static var availableCases: [DayViewStyle] {
allCases.filter { $0.isAvailable }
}
}
class UserDefaultsStore {

View File

@@ -45,20 +45,6 @@ struct OnboardingCustomizeOne: View {
.foregroundColor(.black)
.multilineTextAlignment(.leading)
IconPickerView()
Text(String(localized: "onboarding_title_customize_one_section_two_title"))
.font(.title3)
.padding()
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.black)
DayFilterPickerView()
Text(String(localized: "onboarding_title_customize_one_section_two_note"))
.font(.title3)
.padding()
.fixedSize(horizontal: false, vertical: true)
.foregroundColor(.black)
}
}
.padding()

View File

@@ -47,9 +47,9 @@ final class ExtensionDataProvider {
// Watch uses CloudKit for automatic sync with iPhone
let cloudKitContainerID: String
#if DEBUG
cloudKitContainerID = "iCloud.com.tt.feelsDebug"
cloudKitContainerID = "iCloud.com.88oakapps.feels.debug"
#else
cloudKitContainerID = "iCloud.com.tt.feels"
cloudKitContainerID = "iCloud.com.88oakapps.feels"
#endif
let configuration = ModelConfiguration(

View File

@@ -107,9 +107,9 @@ enum SharedModelContainer {
/// CloudKit container identifier based on build configuration
static var cloudKitContainerID: String {
#if DEBUG
return "iCloud.com.tt.feelsDebug"
return "iCloud.com.88oakapps.feels.debug"
#else
return "iCloud.com.tt.feels"
return "iCloud.com.88oakapps.feels"
#endif
}

View File

@@ -10,8 +10,8 @@ import SwiftUI
import SwiftData
struct Constants {
static let groupShareId = "group.com.tt.feels"
static let groupShareIdDebug = "group.com.tt.feelsDebug"
static let groupShareId = "group.com.88oakapps.feels"
static let groupShareIdDebug = "group.com.88oakapps.feels.debug"
static var currentGroupShareId: String {
#if DEBUG

View File

@@ -77,8 +77,6 @@ struct AddMoodHeaderView: View {
HorizontalVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .cards:
CardVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .radial:
RadialVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .stacked:
StackedVotingView(moodTint: moodTint, onMoodSelected: addItem)
case .aura:
@@ -137,99 +135,62 @@ struct CardVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
private let columns = [
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12),
GridItem(.flexible(), spacing: 12)
]
var body: some View {
LazyVGrid(columns: columns, spacing: 12) {
ForEach(Mood.allValues) { mood in
Button(action: { onMoodSelected(mood) }) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundColor(moodTint.color(forMood: mood))
.frame(maxWidth: .infinity)
.padding(.vertical, 20)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(moodTint.color(forMood: mood).opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(CardButtonStyle())
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
}
GeometryReader { geo in
let spacing: CGFloat = 12
let cardWidth = (geo.size.width - spacing * 2) / 3
// Offset to center bottom row cards between top row cards
// Each bottom card should be centered between two top cards
let bottomOffset = (cardWidth + spacing) / 2
// MARK: - Layout 3: Radial/Semi-circle
struct RadialVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void
var body: some View {
GeometryReader { geometry in
let center = CGPoint(x: geometry.size.width / 2, y: geometry.size.height * 0.9)
let radius = min(geometry.size.width, geometry.size.height) * 0.65
let moods = Mood.allValues
ZStack {
ForEach(Array(moods.enumerated()), id: \.element.id) { index, mood in
let angle = angleForIndex(index, total: moods.count)
let position = positionForAngle(angle, radius: radius, center: center)
Button(action: { onMoodSelected(mood) }) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
.foregroundColor(moodTint.color(forMood: mood))
.padding(12)
.background(
Circle()
.fill(moodTint.color(forMood: mood).opacity(0.1))
)
VStack(spacing: spacing) {
// Top row: Great, Good, Average
HStack(spacing: spacing) {
ForEach(Array(Mood.allValues.prefix(3))) { mood in
cardButton(for: mood, width: cardWidth)
}
.buttonStyle(MoodButtonStyle())
.position(position)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
// Bottom row: Bad, Horrible - centered between top row items
HStack(spacing: spacing) {
ForEach(Array(Mood.allValues.suffix(2))) { mood in
cardButton(for: mood, width: cardWidth)
}
}
.padding(.leading, bottomOffset)
.padding(.trailing, bottomOffset)
}
}
.frame(height: 180)
.frame(height: 190)
.accessibilityElement(children: .contain)
.accessibilityLabel(String(localized: "Mood selection"))
}
private func angleForIndex(_ index: Int, total: Int) -> Double {
// Spread moods across a semi-circle (180 degrees), from left to right
let startAngle = Double.pi // 180 degrees (left)
let endAngle = 0.0 // 0 degrees (right)
let step = (startAngle - endAngle) / Double(total - 1)
return startAngle - (step * Double(index))
}
private func positionForAngle(_ angle: Double, radius: CGFloat, center: CGPoint) -> CGPoint {
CGPoint(
x: center.x + radius * CGFloat(cos(angle)),
y: center.y - radius * CGFloat(sin(angle))
)
private func cardButton(for mood: Mood, width: CGFloat) -> some View {
Button(action: { onMoodSelected(mood) }) {
mood.icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 40, height: 40)
.foregroundColor(moodTint.color(forMood: mood))
.frame(width: width)
.padding(.vertical, 20)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(moodTint.color(forMood: mood).opacity(0.3), lineWidth: 1)
)
}
.buttonStyle(CardButtonStyle())
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Select this mood"))
}
}
// MARK: - Layout 4: Stacked Full-width
// MARK: - Layout 3: Stacked Full-width
struct StackedVotingView: View {
let moodTint: MoodTints
let onMoodSelected: (Mood) -> Void

View File

@@ -101,11 +101,6 @@ struct CustomizeContentView: View {
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
// FILTERS
SettingsSection(title: "Day Filter") {
DayFilterPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
@@ -175,11 +170,6 @@ struct CustomizeView: View {
SettingsSection(title: "Notifications") {
PersonalityPackPickerCompact()
}
// FILTERS
SettingsSection(title: "Day Filter") {
DayFilterPickerCompact()
}
}
.padding(.horizontal, 16)
.padding(.bottom, 32)
@@ -406,14 +396,6 @@ struct VotingLayoutPickerCompact: View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 4) {
ForEach(0..<6, id: \.self) { _ in RoundedRectangle(cornerRadius: 3).frame(width: 10, height: 12) }
}
case .radial:
ZStack {
ForEach(0..<5, id: \.self) { index in
Circle()
.frame(width: 7, height: 7)
.offset(radialOffset(index: index, total: 5, radius: 15))
}
}
case .stacked:
VStack(spacing: 4) {
ForEach(0..<4, id: \.self) { _ in RoundedRectangle(cornerRadius: 2).frame(width: 32, height: 7) }
@@ -486,11 +468,6 @@ struct VotingLayoutPickerCompact: View {
}
}
private func radialOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1))
return CGSize(width: radius * CGFloat(cos(angle)), height: -radius * CGFloat(sin(angle)) + 4)
}
private func orbitOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
let startAngle = -Double.pi / 2
let angleStep = (2 * Double.pi) / Double(total)
@@ -627,59 +604,6 @@ struct PersonalityPackPickerCompact: View {
}
}
// MARK: - Day Filter Picker
struct DayFilterPickerCompact: View {
@StateObject private var filteredDays = DaysFilterClass.shared
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@Environment(\.colorScheme) private var colorScheme
let weekdays = [(Calendar.current.shortWeekdaySymbols[0], 1),
(Calendar.current.shortWeekdaySymbols[1], 2),
(Calendar.current.shortWeekdaySymbols[2], 3),
(Calendar.current.shortWeekdaySymbols[3], 4),
(Calendar.current.shortWeekdaySymbols[4], 5),
(Calendar.current.shortWeekdaySymbols[5], 6),
(Calendar.current.shortWeekdaySymbols[6], 7)]
var body: some View {
VStack(spacing: 14) {
HStack(spacing: 8) {
ForEach(weekdays.indices, id: \.self) { dayIdx in
let day = String(weekdays[dayIdx].0)
let value = weekdays[dayIdx].1
let isActive = filteredDays.currentFilters.contains(value)
Button(action: {
if isActive {
filteredDays.removeFilter(filter: value)
} else {
filteredDays.addFilter(newFilter: value)
}
let impactMed = UIImpactFeedbackGenerator(style: .medium)
impactMed.impactOccurred()
}) {
Text(day.prefix(2).uppercased())
.font(.caption.weight(.semibold))
.foregroundColor(isActive ? .white : theme.currentTheme.labelColor.opacity(0.5))
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isActive ? Color.accentColor : (colorScheme == .dark ? Color(.systemGray5) : .white))
)
}
.buttonStyle(.plain)
}
}
Text(String(localized: "day_picker_view_text"))
.font(.caption)
.foregroundColor(theme.currentTheme.labelColor.opacity(0.5))
.multilineTextAlignment(.center)
}
}
}
// MARK: - Subscription Banner
struct SubscriptionBannerView: View {
@Binding var showSubscriptionStore: Bool
@@ -787,7 +711,7 @@ struct DayViewStylePickerCompact: View {
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(DayViewStyle.allCases, id: \.rawValue) { style in
ForEach(DayViewStyle.availableCases, id: \.rawValue) { style in
Button(action: {
if UIAccessibility.isReduceMotionEnabled {
dayViewStyle = style

View File

@@ -90,14 +90,6 @@ struct VotingLayoutPickerView: View {
.frame(width: 10, height: 12)
}
}
case .radial:
ZStack {
ForEach(0..<5) { index in
Circle()
.frame(width: 6, height: 6)
.offset(radialOffset(index: index, total: 5, radius: 16))
}
}
case .stacked:
VStack(spacing: 3) {
ForEach(0..<4) { _ in
@@ -179,14 +171,6 @@ struct VotingLayoutPickerView: View {
}
}
private func radialOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
let angle = Double.pi - (Double.pi * Double(index) / Double(total - 1))
return CGSize(
width: radius * CGFloat(cos(angle)),
height: -radius * CGFloat(sin(angle)) + 4
)
}
private func orbitOffset(index: Int, total: Int, radius: CGFloat) -> CGSize {
// Start from top (-π/2) and go clockwise
let startAngle = -Double.pi / 2

View File

@@ -21,6 +21,8 @@ struct SettingsContentView: View {
@State private var showTrialDatePicker = false
@State private var isExportingWidgets = false
@State private var widgetExportPath: URL?
@State private var isDeletingHealthKitData = false
@State private var healthKitDeleteResult: String?
@StateObject private var healthService = HealthService.shared
@AppStorage(UserDefaultsStore.Keys.firstLaunchDate.rawValue, store: GroupUserDefaults.groupDefaults)
@@ -62,6 +64,7 @@ struct SettingsContentView: View {
tipsPreviewButton
testNotificationsButton
exportWidgetsButton
deleteHealthKitDataButton
clearDataButton
#endif
@@ -475,6 +478,58 @@ struct SettingsContentView: View {
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var deleteHealthKitDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor
Button {
isDeletingHealthKitData = true
healthKitDeleteResult = nil
Task {
do {
let count = try await HealthKitManager.shared.deleteAllMoods()
healthKitDeleteResult = "✓ Deleted \(count) records"
} catch {
healthKitDeleteResult = "✗ Error: \(error.localizedDescription)"
}
isDeletingHealthKitData = false
}
} label: {
HStack(spacing: 12) {
if isDeletingHealthKitData {
ProgressView()
.frame(width: 32)
} else {
Image(systemName: "heart.slash.fill")
.font(.title2)
.foregroundColor(.red)
.frame(width: 32)
}
VStack(alignment: .leading, spacing: 2) {
Text("Delete HealthKit Data")
.foregroundColor(textColor)
if let result = healthKitDeleteResult {
Text(result)
.font(.caption)
.foregroundColor(result.contains("") ? .green : .red)
} else {
Text("Remove all State of Mind records")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
.padding()
}
.disabled(isDeletingHealthKitData)
}
.fixedSize(horizontal: false, vertical: true)
.cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight])
}
private var clearDataButton: some View {
ZStack {
theme.currentTheme.secondaryBGColor