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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user