v1.1 polish: accessibility, error logging, localization, and code quality sweep

- Wrap 30+ production print() statements in #if DEBUG guards across 18 files
- Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets
- Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views
- Add text alternatives for color-only indicators (progress dots, mood circles)
- Localize raw string literals in NoteEditorView, EntryDetailView, widgets
- Replace 25+ silent try? with do/catch + AppLogger error logging
- Replace hardcoded font sizes with semantic Dynamic Type fonts
- Fix FIXME in IconPickerView (log icon change errors)
- Extract magic animation delays to named constants across 8 files
- Add widget empty state "Log your first mood!" messaging
- Hide decorative images from VoiceOver, add labels to ColorPickers
- Remove stale TODO in Color+Codable (alpha change deferred for migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey T
2026-03-26 20:09:14 -05:00
parent 4d9e906c4d
commit 1f040ab676
41 changed files with 427 additions and 107 deletions

View File

@@ -22,8 +22,9 @@ struct ContentView: View {
// Show voting UI // Show voting UI
VStack(spacing: 8) { VStack(spacing: 8) {
Text("How do you feel?") Text("How do you feel?")
.font(.system(size: 16, weight: .medium)) .font(.headline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.accessibilityAddTraits(.isHeader)
// Top row: Great, Good, Average // Top row: Great, Good, Average
HStack(spacing: 8) { HStack(spacing: 8) {
@@ -87,11 +88,14 @@ struct AlreadyRatedView: View {
VStack(spacing: 12) { VStack(spacing: 12) {
Text(mood.watchEmoji) Text(mood.watchEmoji)
.font(.system(size: 50)) .font(.system(size: 50))
.accessibilityHidden(true)
Text("Logged!") Text("Logged!")
.font(.system(size: 18, weight: .semibold)) .font(.title3.weight(.semibold))
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(mood.strValue) mood logged"))
} }
} }
@@ -104,7 +108,7 @@ struct MoodButton: View {
var body: some View { var body: some View {
Button(action: action) { Button(action: action) {
Text(mood.watchEmoji) Text(mood.watchEmoji)
.font(.system(size: 28)) .font(.title2)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: 50) .frame(height: 50)
.background(mood.watchColor.opacity(0.3)) .background(mood.watchColor.opacity(0.3))
@@ -112,6 +116,8 @@ struct MoodButton: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityIdentifier(AccessibilityID.Watch.moodButton(mood.strValue)) .accessibilityIdentifier(AccessibilityID.Watch.moodButton(mood.strValue))
.accessibilityLabel(String(localized: "Log \(mood.strValue) mood"))
.accessibilityHint(String(localized: "Double tap to log your mood as \(mood.strValue)"))
} }
} }

View File

@@ -24,9 +24,12 @@ struct MoodStreakLiveActivity: Widget {
HStack(spacing: 8) { HStack(spacing: 8) {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)") Text("\(context.state.currentStreak)")
.font(.title2.bold()) .font(.title2.bold())
} }
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
} }
DynamicIslandExpandedRegion(.trailing) { DynamicIslandExpandedRegion(.trailing) {
@@ -34,6 +37,7 @@ struct MoodStreakLiveActivity: Widget {
Image(systemName: "checkmark.circle.fill") Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green) .foregroundColor(.green)
.font(.title2) .font(.title2)
.accessibilityLabel(String(localized: "Mood logged today"))
} else { } else {
Text("Log now") Text("Log now")
.font(.caption) .font(.caption)
@@ -56,20 +60,25 @@ struct MoodStreakLiveActivity: Widget {
Circle() Circle()
.fill(Color(hex: context.state.lastMoodColor)) .fill(Color(hex: context.state.lastMoodColor))
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.accessibilityHidden(true)
Text("Today: \(context.state.lastMoodLogged)") Text("Today: \(context.state.lastMoodLogged)")
.font(.subheadline) .font(.subheadline)
} }
.accessibilityElement(children: .combine)
} }
} }
} compactLeading: { } compactLeading: {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
.accessibilityLabel(String(localized: "Streak"))
} compactTrailing: { } compactTrailing: {
Text("\(context.state.currentStreak)") Text("\(context.state.currentStreak)")
.font(.caption.bold()) .font(.caption.bold())
.accessibilityLabel(String(localized: "\(context.state.currentStreak) days"))
} minimal: { } minimal: {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.foregroundColor(.orange) .foregroundColor(.orange)
.accessibilityLabel(String(localized: "Mood streak"))
} }
} }
} }
@@ -87,12 +96,15 @@ struct MoodStreakLockScreenView: View {
Image(systemName: "flame.fill") Image(systemName: "flame.fill")
.font(.title) .font(.title)
.foregroundColor(.orange) .foregroundColor(.orange)
.accessibilityHidden(true)
Text("\(context.state.currentStreak)") Text("\(context.state.currentStreak)")
.font(.title.bold()) .font(.title.bold())
Text("day streak") Text("day streak")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
} }
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
Divider() Divider()
.frame(height: 50) .frame(height: 50)
@@ -104,6 +116,7 @@ struct MoodStreakLockScreenView: View {
Circle() Circle()
.fill(Color(hex: context.state.lastMoodColor)) .fill(Color(hex: context.state.lastMoodColor))
.frame(width: 24, height: 24) .frame(width: 24, height: 24)
.accessibilityHidden(true)
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Today's mood") Text("Today's mood")
.font(.caption) .font(.caption)
@@ -112,6 +125,7 @@ struct MoodStreakLockScreenView: View {
.font(.headline) .font(.headline)
} }
} }
.accessibilityElement(children: .combine)
} else { } else {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!") Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")

View File

@@ -82,6 +82,8 @@ struct SmallWidgetView: View {
return f return f
} }
private var isSampleData: Bool
init(entry: Provider.Entry) { init(entry: Provider.Entry) {
self.entry = entry self.entry = entry
let realData = TimeLineCreator.createViews(daysBack: 2) let realData = TimeLineCreator.createViews(daysBack: 2)
@@ -89,6 +91,7 @@ struct SmallWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing) return view.color != moodTint.color(forMood: .missing)
} }
isSampleData = !hasRealData
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
} }
@@ -98,6 +101,13 @@ struct SmallWidgetView: View {
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription) VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else if let today = todayView { } else if let today = todayView {
VStack(spacing: 0) { VStack(spacing: 0) {
if isSampleData {
Text(String(localized: "Log your first mood!"))
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
Spacer() Spacer()
// Large mood icon // Large mood icon
@@ -152,6 +162,8 @@ struct MediumWidgetView: View {
return f return f
} }
private var isSampleData: Bool
init(entry: Provider.Entry) { init(entry: Provider.Entry) {
self.entry = entry self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5)) let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
@@ -159,6 +171,7 @@ struct MediumWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing) return view.color != moodTint.color(forMood: .missing)
} }
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5) timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
} }
@@ -183,11 +196,19 @@ struct MediumWidgetView: View {
Text("Last 5 Days") Text("Last 5 Days")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text("·") if isSampleData {
.foregroundStyle(.secondary) Text("·")
Text(headerDateRange) .foregroundStyle(.secondary)
.font(.caption) Text(String(localized: "Log your first mood!"))
.foregroundStyle(.secondary) .font(.caption)
.foregroundStyle(.secondary)
} else {
Text("·")
.foregroundStyle(.secondary)
Text(headerDateRange)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer() Spacer()
} }
.padding(.horizontal, 14) .padding(.horizontal, 14)
@@ -264,6 +285,8 @@ struct LargeWidgetView: View {
!entry.hasVotedToday !entry.hasVotedToday
} }
private var isSampleData: Bool
init(entry: Provider.Entry) { init(entry: Provider.Entry) {
self.entry = entry self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)) let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
@@ -271,6 +294,7 @@ struct LargeWidgetView: View {
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing) return view.color != moodTint.color(forMood: .missing)
} }
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10) timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
} }
@@ -301,7 +325,7 @@ struct LargeWidgetView: View {
Text("Last 10 Days") Text("Last 10 Days")
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
Text(headerDateRange) Text(isSampleData ? String(localized: "Log your first mood!") : headerDateRange)
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }

View File

@@ -158,10 +158,13 @@ struct VotedStatsView: View {
Circle() Circle()
.fill(moodTint.color(forMood: mood)) .fill(moodTint.color(forMood: mood))
.frame(width: 8, height: 8) .frame(width: 8, height: 8)
.accessibilityHidden(true)
Text("\(count)") Text("\(count)")
.font(.caption2) .font(.caption2)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
.accessibilityElement(children: .combine)
.accessibilityLabel("\(count) \(mood.strValue)")
} }
} }
} }

View File

@@ -58,8 +58,8 @@ struct VotingView: View {
VStack(spacing: 0) { VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered // Top 50%: Text left-aligned, vertically centered
HStack { HStack {
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.system(size: 20, weight: .semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(2) .lineLimit(2)
@@ -159,8 +159,8 @@ struct LargeVotingView: View {
GeometryReader { geo in GeometryReader { geo in
VStack(spacing: 0) { VStack(spacing: 0) {
// Top 33%: Title centered // Top 33%: Title centered
Text(hasSubscription ? promptText : "Subscribe to track your mood") Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
.font(.system(size: 24, weight: .semibold)) .font(.title2.weight(.semibold))
.foregroundStyle(.primary) .foregroundStyle(.primary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.lineLimit(2) .lineLimit(2)

View File

@@ -51,7 +51,9 @@ class BGTask {
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
#if DEBUG
print("Could not schedule weather retry: \(error)") print("Could not schedule weather retry: \(error)")
#endif
} }
} }
@@ -67,7 +69,9 @@ class BGTask {
do { do {
try BGTaskScheduler.shared.submit(request) try BGTaskScheduler.shared.submit(request)
} catch { } catch {
#if DEBUG
print("Could not schedule image fetch: \(error)") print("Could not schedule image fetch: \(error)")
#endif
} }
} }
} }

View File

@@ -134,7 +134,6 @@ extension Color {
} }
extension Color: @retroactive RawRepresentable { extension Color: @retroactive RawRepresentable {
// TODO: Sort out alpha
public init?(rawValue: Int) { public init?(rawValue: Int) {
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF

View File

@@ -307,8 +307,16 @@ class IAPManager: ObservableObject {
// Get renewal info // Get renewal info
if let product = currentProduct, if let product = currentProduct,
let subscription = product.subscription, let subscription = product.subscription {
let statuses = try? await subscription.status { let statuses: [Product.SubscriptionInfo.Status]
do {
statuses = try await subscription.status
} catch {
AppLogger.iap.error("Failed to fetch subscription status for \(product.id): \(error)")
// Fallback handled below
state = .subscribed(expirationDate: transaction.expirationDate, willAutoRenew: false)
return true
}
var hadVerifiedStatus = false var hadVerifiedStatus = false
for status in statuses { for status in statuses {

View File

@@ -69,11 +69,15 @@ class LocalNotification {
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger) let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
UNUserNotificationCenter.current().add(request) { (error : Error?) in UNUserNotificationCenter.current().add(request) { (error : Error?) in
if let theError = error { if let theError = error {
#if DEBUG
print(theError.localizedDescription) print(theError.localizedDescription)
#endif
} }
} }
case .failure(let error): case .failure(let error):
#if DEBUG
print(error) print(error)
#endif
// Todo: show enable this // Todo: show enable this
break break
} }

View File

@@ -6,6 +6,7 @@
// //
import Foundation import Foundation
import os.log
enum VotingLayoutStyle: Int, CaseIterable { enum VotingLayoutStyle: Int, CaseIterable {
case horizontal = 0 // Current: 5 buttons in a row case horizontal = 0 // Current: 5 buttons in a row
@@ -177,6 +178,8 @@ enum DayViewStyle: Int, CaseIterable {
} }
} }
private let userDefaultsLogger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.88oakapps.reflect", category: "UserDefaults")
class UserDefaultsStore { class UserDefaultsStore {
enum Keys: String { enum Keys: String {
case savedOnboardingData case savedOnboardingData
@@ -226,15 +229,18 @@ class UserDefaultsStore {
} }
// Decode and cache // Decode and cache
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data, if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data {
let model = try? JSONDecoder().decode(OnboardingData.self, from: data) { do {
cachedOnboardingData = model let model = try JSONDecoder().decode(OnboardingData.self, from: data)
return model cachedOnboardingData = model
} else { return model
let defaultData = OnboardingData() } catch {
cachedOnboardingData = defaultData userDefaultsLogger.error("Failed to decode onboarding data: \(error)")
return defaultData }
} }
let defaultData = OnboardingData()
cachedOnboardingData = defaultData
return defaultData
} }
/// Invalidate cached onboarding data (call when data might have changed externally) /// Invalidate cached onboarding data (call when data might have changed externally)
@@ -251,7 +257,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(onboardingData) let data = try JSONEncoder().encode(onboardingData)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
} catch { } catch {
print("Error saving onboarding: \(error)") userDefaultsLogger.error("Failed to encode onboarding data: \(error)")
} }
// Re-cache the saved data // Re-cache the saved data
@@ -314,28 +320,38 @@ class UserDefaultsStore {
} }
static func getCustomWidgets() -> [CustomWidgetModel] { static func getCustomWidgets() -> [CustomWidgetModel] {
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data, if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
let model = try? JSONDecoder().decode([CustomWidgetModel].self, from: data) { do {
return model let model = try JSONDecoder().decode([CustomWidgetModel].self, from: data)
} else { return model
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue) } catch {
userDefaultsLogger.error("Failed to decode custom widgets: \(error)")
let widget = CustomWidgetModel.randomWidget
widget.isSaved = true
let widgets = [widget]
guard let data = try? JSONEncoder().encode(widgets) else {
return widgets
}
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
let models = try? JSONDecoder().decode([CustomWidgetModel].self, from: savedData) {
return models.sorted { $0.createdDate < $1.createdDate }
} else {
return widgets
} }
} }
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
let widget = CustomWidgetModel.randomWidget
widget.isSaved = true
let widgets = [widget]
do {
let data = try JSONEncoder().encode(widgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch {
userDefaultsLogger.error("Failed to encode default custom widgets: \(error)")
return widgets
}
if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
do {
let models = try JSONDecoder().decode([CustomWidgetModel].self, from: savedData)
return models.sorted { $0.createdDate < $1.createdDate }
} catch {
userDefaultsLogger.error("Failed to decode saved custom widgets: \(error)")
}
}
return widgets
} }
@discardableResult @discardableResult
@@ -366,7 +382,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(existingWidgets) let data = try JSONEncoder().encode(existingWidgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch { } catch {
print("Error saving custom widget: \(error)") userDefaultsLogger.error("Failed to encode custom widget for save: \(error)")
} }
return UserDefaultsStore.getCustomWidgets() return UserDefaultsStore.getCustomWidgets()
} }
@@ -396,7 +412,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(existingWidgets) let data = try JSONEncoder().encode(existingWidgets)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
} catch { } catch {
print("Error deleting custom widget: \(error)") userDefaultsLogger.error("Failed to encode custom widgets for delete: \(error)")
} }
return UserDefaultsStore.getCustomWidgets() return UserDefaultsStore.getCustomWidgets()
} }
@@ -407,7 +423,7 @@ class UserDefaultsStore {
let model = try JSONDecoder().decode(SavedMoodTint.self, from: data) let model = try JSONDecoder().decode(SavedMoodTint.self, from: data)
return model return model
} catch { } catch {
print(error) userDefaultsLogger.error("Failed to decode custom mood tint: \(error)")
} }
} }
return SavedMoodTint() return SavedMoodTint()
@@ -428,7 +444,7 @@ class UserDefaultsStore {
let data = try JSONEncoder().encode(customTint) let data = try JSONEncoder().encode(customTint)
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue)
} catch { } catch {
print("Error saving custom mood tint: \(error)") userDefaultsLogger.error("Failed to encode custom mood tint: \(error)")
} }
return UserDefaultsStore.getCustomMoodTint() return UserDefaultsStore.getCustomMoodTint()
} }

View File

@@ -37,7 +37,9 @@ class LiveActivityManager: ObservableObject {
// Start a mood streak Live Activity // Start a mood streak Live Activity
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) { func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
guard ActivityAuthorizationInfo().areActivitiesEnabled else { guard ActivityAuthorizationInfo().areActivitiesEnabled else {
#if DEBUG
print("Live Activities not enabled") print("Live Activities not enabled")
#endif
return return
} }
@@ -76,7 +78,9 @@ class LiveActivityManager: ObservableObject {
) )
currentActivity = activity currentActivity = activity
} catch { } catch {
#if DEBUG
print("Error starting Live Activity: \(error)") print("Error starting Live Activity: \(error)")
#endif
} }
} }
@@ -257,23 +261,31 @@ class LiveActivityScheduler: ObservableObject {
invalidateTimers() invalidateTimers()
guard ActivityAuthorizationInfo().areActivitiesEnabled else { guard ActivityAuthorizationInfo().areActivitiesEnabled else {
#if DEBUG
print("[LiveActivity] Live Activities not enabled by user") print("[LiveActivity] Live Activities not enabled by user")
#endif
return return
} }
let now = Date() let now = Date()
guard let startTime = getStartTime(), guard let startTime = getStartTime(),
let endTime = getEndTime() else { let endTime = getEndTime() else {
#if DEBUG
print("[LiveActivity] No rating time configured - skipping") print("[LiveActivity] No rating time configured - skipping")
#endif
return return
} }
let hasRated = hasRatedToday() let hasRated = hasRatedToday()
#if DEBUG
print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)") print("[LiveActivity] Schedule check - now: \(now), start: \(startTime), end: \(endTime), hasRated: \(hasRated)")
#endif
// If user has already rated today, don't show activity - schedule for next day // If user has already rated today, don't show activity - schedule for next day
if hasRated { if hasRated {
#if DEBUG
print("[LiveActivity] User already rated today - scheduling for next day") print("[LiveActivity] User already rated today - scheduling for next day")
#endif
scheduleForNextDay() scheduleForNextDay()
return return
} }
@@ -281,7 +293,9 @@ class LiveActivityScheduler: ObservableObject {
// Check if we're within the activity window (rating time to 5 hrs after) // Check if we're within the activity window (rating time to 5 hrs after)
if now >= startTime && now <= endTime { if now >= startTime && now <= endTime {
// Start activity immediately // Start activity immediately
#if DEBUG
print("[LiveActivity] Within window - starting activity now") print("[LiveActivity] Within window - starting activity now")
#endif
let streak = calculateStreak() let streak = calculateStreak()
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false) LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
@@ -289,12 +303,16 @@ class LiveActivityScheduler: ObservableObject {
scheduleEnd(at: endTime) scheduleEnd(at: endTime)
} else if now < startTime { } else if now < startTime {
// Schedule start for later today // Schedule start for later today
#if DEBUG
print("[LiveActivity] Before window - scheduling start for \(startTime)") print("[LiveActivity] Before window - scheduling start for \(startTime)")
#endif
scheduleStart(at: startTime) scheduleStart(at: startTime)
scheduleEnd(at: endTime) scheduleEnd(at: endTime)
} else { } else {
// Past the window for today, schedule for tomorrow // Past the window for today, schedule for tomorrow
#if DEBUG
print("[LiveActivity] Past window - scheduling for tomorrow") print("[LiveActivity] Past window - scheduling for tomorrow")
#endif
scheduleForNextDay() scheduleForNextDay()
} }
} }

View File

@@ -12,6 +12,10 @@ import WidgetKit
@main @main
struct ReflectApp: App { struct ReflectApp: App {
private enum AnimationConstants {
static let deepLinkHandlingDelay: TimeInterval = 0.3
}
@Environment(\.scenePhase) private var scenePhase @Environment(\.scenePhase) private var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -83,7 +87,7 @@ struct ReflectApp: App {
} }
if let url = AppDelegate.pendingDeepLinkURL { if let url = AppDelegate.pendingDeepLinkURL {
AppDelegate.pendingDeepLinkURL = nil AppDelegate.pendingDeepLinkURL = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.deepLinkHandlingDelay) {
handleDeepLink(url) handleDeepLink(url)
} }
} }

View File

@@ -238,6 +238,10 @@ class ReflectTipsManager: ObservableObject {
// MARK: - View Modifier for Easy Integration // MARK: - View Modifier for Easy Integration
struct ReflectTipModifier: ViewModifier { struct ReflectTipModifier: ViewModifier {
private enum AnimationConstants {
static let tipPresentationDelay: TimeInterval = 0.5
}
let tip: any ReflectTip let tip: any ReflectTip
let gradientColors: [Color] let gradientColors: [Color]
@@ -254,7 +258,7 @@ struct ReflectTipModifier: ViewModifier {
// Delay tip presentation to ensure view hierarchy is fully established // Delay tip presentation to ensure view hierarchy is fully established
// This prevents "presenting from detached view controller" errors // This prevents "presenting from detached view controller" errors
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.tipPresentationDelay) {
if ReflectTipsManager.shared.shouldShowTip(tip) { if ReflectTipsManager.shared.shouldShowTip(tip) {
showSheet = true showSheet = true
} }

View File

@@ -102,7 +102,9 @@ class BiometricAuthManager: ObservableObject {
} }
return success return success
} catch { } catch {
#if DEBUG
print("Authentication failed: \(error.localizedDescription)") print("Authentication failed: \(error.localizedDescription)")
#endif
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription)) AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
// If biometrics failed, try device passcode as fallback // If biometrics failed, try device passcode as fallback
@@ -126,7 +128,9 @@ class BiometricAuthManager: ObservableObject {
isUnlocked = success isUnlocked = success
return success return success
} catch { } catch {
#if DEBUG
print("Passcode authentication failed: \(error.localizedDescription)") print("Passcode authentication failed: \(error.localizedDescription)")
#endif
return false return false
} }
} }
@@ -146,7 +150,9 @@ class BiometricAuthManager: ObservableObject {
// Only allow enabling if biometrics are available // Only allow enabling if biometrics are available
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else { guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
#if DEBUG
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")") print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
#endif
return false return false
} }
@@ -164,7 +170,9 @@ class BiometricAuthManager: ObservableObject {
return success return success
} catch { } catch {
#if DEBUG
print("Failed to enable lock: \(error.localizedDescription)") print("Failed to enable lock: \(error.localizedDescription)")
#endif
return false return false
} }
} }

View File

@@ -87,7 +87,9 @@ class ExportService {
trackDataExported(format: "csv", count: entries.count) trackDataExported(format: "csv", count: entries.count)
return tempURL return tempURL
} catch { } catch {
#if DEBUG
print("ExportService: Failed to write CSV: \(error)") print("ExportService: Failed to write CSV: \(error)")
#endif
return nil return nil
} }
} }
@@ -177,7 +179,9 @@ class ExportService {
try data.write(to: tempURL) try data.write(to: tempURL)
return tempURL return tempURL
} catch { } catch {
#if DEBUG
print("ExportService: Failed to write PDF: \(error)") print("ExportService: Failed to write PDF: \(error)")
#endif
return nil return nil
} }
} }

View File

@@ -7,6 +7,7 @@
import Foundation import Foundation
import FoundationModels import FoundationModels
import os.log
/// Error types for insight generation /// Error types for insight generation
enum InsightGenerationError: Error, LocalizedError { enum InsightGenerationError: Error, LocalizedError {
@@ -244,9 +245,7 @@ class FoundationModelsInsightService: ObservableObject {
return insights return insights
} catch { } catch {
// Log detailed error for debugging // Log detailed error for debugging
print("AI Insight generation failed for '\(periodName)': \(error)") AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)")
print(" Error type: \(type(of: error))")
print(" Localized: \(error.localizedDescription)")
lastError = .generationFailed(underlying: error) lastError = .generationFailed(underlying: error)
throw lastError! throw lastError!

View File

@@ -71,7 +71,9 @@ class HealthService: ObservableObject {
func requestAuthorization() async -> Bool { func requestAuthorization() async -> Bool {
guard isAvailable else { guard isAvailable else {
#if DEBUG
print("HealthService: HealthKit not available on this device") print("HealthService: HealthKit not available on this device")
#endif
return false return false
} }
@@ -82,7 +84,9 @@ class HealthService: ObservableObject {
AnalyticsManager.shared.track(.healthKitAuthorized) AnalyticsManager.shared.track(.healthKitAuthorized)
return true return true
} catch { } catch {
#if DEBUG
print("HealthService: Authorization failed: \(error.localizedDescription)") print("HealthService: Authorization failed: \(error.localizedDescription)")
#endif
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription)) AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
return false return false
} }

View File

@@ -8,6 +8,7 @@
#if DEBUG #if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports insights view screenshots for App Store marketing /// Exports insights view screenshots for App Store marketing
@MainActor @MainActor
@@ -28,7 +29,12 @@ class InsightsExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create insights export directory: \(error)")
return nil
}
var totalExported = 0 var totalExported = 0
@@ -95,7 +101,11 @@ class InsightsExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { if let data = image.pngData() {
try? data.write(to: url) do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write insights image '\(name)': \(error)")
}
} }
} }
} }

View File

@@ -92,7 +92,11 @@ class PhotoManager: ObservableObject {
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename) let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
if let thumbnail = createThumbnail(from: image), if let thumbnail = createThumbnail(from: image),
let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) { let thumbnailData = thumbnail.jpegData(compressionQuality: 0.6) {
try? thumbnailData.write(to: thumbnailURL) do {
try thumbnailData.write(to: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to save thumbnail: \(error)")
}
} }
AnalyticsManager.shared.track(.photoAdded) AnalyticsManager.shared.track(.photoAdded)
@@ -107,13 +111,21 @@ class PhotoManager: ObservableObject {
let filename = "\(id.uuidString).jpg" let filename = "\(id.uuidString).jpg"
let fullURL = photosDir.appendingPathComponent(filename) let fullURL = photosDir.appendingPathComponent(filename)
guard FileManager.default.fileExists(atPath: fullURL.path), guard FileManager.default.fileExists(atPath: fullURL.path) else {
let data = try? Data(contentsOf: fullURL),
let image = UIImage(data: data) else {
return nil return nil
} }
return image do {
let data = try Data(contentsOf: fullURL)
guard let image = UIImage(data: data) else {
AppLogger.photos.error("Failed to create UIImage from photo data: \(id)")
return nil
}
return image
} catch {
AppLogger.photos.error("Failed to read photo data for \(id): \(error)")
return nil
}
} }
func loadThumbnail(id: UUID) -> UIImage? { func loadThumbnail(id: UUID) -> UIImage? {
@@ -123,10 +135,15 @@ class PhotoManager: ObservableObject {
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename) let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
// Try thumbnail first // Try thumbnail first
if FileManager.default.fileExists(atPath: thumbnailURL.path), if FileManager.default.fileExists(atPath: thumbnailURL.path) {
let data = try? Data(contentsOf: thumbnailURL), do {
let image = UIImage(data: data) { let data = try Data(contentsOf: thumbnailURL)
return image if let image = UIImage(data: data) {
return image
}
} catch {
AppLogger.photos.error("Failed to read thumbnail data for \(id): \(error)")
}
} }
// Fall back to full image if thumbnail doesn't exist // Fall back to full image if thumbnail doesn't exist
@@ -159,7 +176,11 @@ class PhotoManager: ObservableObject {
// Delete thumbnail // Delete thumbnail
if FileManager.default.fileExists(atPath: thumbnailURL.path) { if FileManager.default.fileExists(atPath: thumbnailURL.path) {
try? FileManager.default.removeItem(at: thumbnailURL) do {
try FileManager.default.removeItem(at: thumbnailURL)
} catch {
AppLogger.photos.error("Failed to delete thumbnail: \(error)")
}
} }
if success { if success {
@@ -197,8 +218,13 @@ class PhotoManager: ObservableObject {
var totalPhotoCount: Int { var totalPhotoCount: Int {
guard let photosDir = photosDirectory else { return 0 } guard let photosDir = photosDirectory else { return 0 }
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path) do {
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 0 let files = try FileManager.default.contentsOfDirectory(atPath: photosDir.path)
return files.filter { $0.hasSuffix(".jpg") }.count
} catch {
AppLogger.photos.error("Failed to list photos directory: \(error)")
return 0
}
} }
var totalStorageUsed: Int64 { var totalStorageUsed: Int64 {

View File

@@ -8,6 +8,7 @@
#if DEBUG #if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports sharing template screenshots for App Store marketing /// Exports sharing template screenshots for App Store marketing
@MainActor @MainActor
@@ -21,13 +22,23 @@ class SharingScreenshotExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create sharing export directory: \(error)")
return nil
}
// Create subdirectories // Create subdirectories
let origDir = exportPath.appendingPathComponent("originals", isDirectory: true) let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
let varDir = exportPath.appendingPathComponent("variations", isDirectory: true) let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
try? FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true) do {
try? FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create sharing subdirectories: \(error)")
return nil
}
var totalExported = 0 var totalExported = 0
let distantPast = Date(timeIntervalSince1970: 0) let distantPast = Date(timeIntervalSince1970: 0)
@@ -167,7 +178,7 @@ class SharingScreenshotExporter {
try data.write(to: url) try data.write(to: url)
return true return true
} catch { } catch {
print("Failed to save \(name): \(error)") AppLogger.export.error("Failed to save sharing screenshot '\(name)': \(error)")
} }
} }
return false return false

View File

@@ -9,6 +9,7 @@
#if DEBUG #if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports watch view previews to PNG files for App Store screenshots /// Exports watch view previews to PNG files for App Store screenshots
@MainActor @MainActor
@@ -76,7 +77,12 @@ class WatchExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create watch export directory: \(error)")
return nil
}
var totalExported = 0 var totalExported = 0
@@ -85,7 +91,12 @@ class WatchExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create watch variant directory '\(folderName)': \(error)")
continue
}
let config = WatchExportConfig( let config = WatchExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -242,7 +253,11 @@ class WatchExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { if let data = image.pngData() {
try? data.write(to: url) do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write watch image '\(name)': \(error)")
}
} }
} }
} }

View File

@@ -9,6 +9,7 @@
#if DEBUG #if DEBUG
import SwiftUI import SwiftUI
import UIKit import UIKit
import os.log
/// Exports widget previews to PNG files for App Store screenshots /// Exports widget previews to PNG files for App Store screenshots
@MainActor @MainActor
@@ -76,7 +77,12 @@ class WidgetExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create widget export directory: \(error)")
return nil
}
var totalExported = 0 var totalExported = 0
@@ -85,7 +91,12 @@ class WidgetExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create variant directory '\(folderName)': \(error)")
continue
}
let config = WidgetExportConfig( let config = WidgetExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -155,7 +166,12 @@ class WidgetExporter {
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true) let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create current config export directory: \(error)")
return nil
}
let config = WidgetExportConfig( let config = WidgetExportConfig(
moodTint: UserDefaultsStore.moodTintable(), moodTint: UserDefaultsStore.moodTintable(),
@@ -177,7 +193,12 @@ class WidgetExporter {
// Clean and create export directory // Clean and create export directory
try? FileManager.default.removeItem(at: exportPath) try? FileManager.default.removeItem(at: exportPath)
try? FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: exportPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create voting layout export directory: \(error)")
return nil
}
var totalExported = 0 var totalExported = 0
@@ -186,7 +207,12 @@ class WidgetExporter {
for iconOption in allIcons { for iconOption in allIcons {
let folderName = "\(tintOption.name)_\(iconOption.name)" let folderName = "\(tintOption.name)_\(iconOption.name)"
let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true) let variantPath = exportPath.appendingPathComponent(folderName, isDirectory: true)
try? FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true) do {
try FileManager.default.createDirectory(at: variantPath, withIntermediateDirectories: true)
} catch {
AppLogger.export.error("Failed to create voting variant directory '\(folderName)': \(error)")
continue
}
let config = WidgetExportConfig( let config = WidgetExportConfig(
moodTint: tintOption.tint, moodTint: tintOption.tint,
@@ -372,7 +398,11 @@ class WidgetExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { if let data = image.pngData() {
try? data.write(to: url) do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write widget image '\(name)': \(error)")
}
} }
} }
} }
@@ -384,7 +414,11 @@ class WidgetExporter {
if let image = renderer.uiImage { if let image = renderer.uiImage {
let url = folder.appendingPathComponent("\(name).png") let url = folder.appendingPathComponent("\(name).png")
if let data = image.pngData() { if let data = image.pngData() {
try? data.write(to: url) do {
try data.write(to: url)
} catch {
AppLogger.export.error("Failed to write live activity image '\(name)': \(error)")
}
} }
} }
} }

View File

@@ -305,6 +305,12 @@ struct FlipRevealAnimation: View {
struct ShatterReformAnimation: View { struct ShatterReformAnimation: View {
let mood: Mood let mood: Mood
private enum AnimationConstants {
static let shatterPhaseDuration: TimeInterval = 0.6
static let checkmarkAppearDelay: TimeInterval = 1.1
static let fadeOutDelay: TimeInterval = 1.8
}
@State private var shardOffsets: [CGSize] = [] @State private var shardOffsets: [CGSize] = []
@State private var shardRotations: [Double] = [] @State private var shardRotations: [Double] = []
@State private var shardOpacities: [Double] = [] @State private var shardOpacities: [Double] = []
@@ -354,7 +360,7 @@ struct ShatterReformAnimation: View {
// Phase 2: Converge to center and fade // Phase 2: Converge to center and fade
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .seconds(0.6)) try? await Task.sleep(for: .seconds(AnimationConstants.shatterPhaseDuration))
phase = .reform phase = .reform
withAnimation(.easeInOut(duration: 0.5)) { withAnimation(.easeInOut(duration: 0.5)) {
for i in 0..<shardCount { for i in 0..<shardCount {
@@ -367,7 +373,7 @@ struct ShatterReformAnimation: View {
// Phase 3: Show checkmark // Phase 3: Show checkmark
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .seconds(1.1)) try? await Task.sleep(for: .seconds(AnimationConstants.checkmarkAppearDelay))
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) { withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
checkmarkOpacity = 1 checkmarkOpacity = 1
} }
@@ -375,7 +381,7 @@ struct ShatterReformAnimation: View {
// Phase 4: Fade out // Phase 4: Fade out
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .seconds(1.8)) try? await Task.sleep(for: .seconds(AnimationConstants.fadeOutDelay))
withAnimation(.easeOut(duration: 0.3)) { withAnimation(.easeOut(duration: 0.3)) {
checkmarkOpacity = 0 checkmarkOpacity = 0
} }

View File

@@ -182,6 +182,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_background_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bg")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bg"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -193,6 +194,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_inner_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("inner")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("inner"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -204,6 +206,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_face_outline_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("stroke")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("stroke"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -217,6 +220,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_left_eye_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("leftEye")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("leftEye"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -228,6 +232,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_right_eye_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("rightEye")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("rightEye"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -239,6 +244,7 @@ struct CreateWidgetView: View {
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth")) AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
} }
.labelsHidden() .labelsHidden()
.accessibilityLabel(String(localized: "create_widget_view_mouth_color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("mouth")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("mouth"))
} }
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -264,14 +270,20 @@ struct CreateWidgetView: View {
.onTapGesture { .onTapGesture {
update(background: bg) update(background: bg)
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select background \(bg.rawValue)"))
} }
mixBG mixBG
.accessibilityIdentifier(AccessibilityID.CustomWidget.randomBackgroundButton) .accessibilityIdentifier(AccessibilityID.CustomWidget.randomBackgroundButton)
.onTapGesture { .onTapGesture {
update(background: .random) update(background: .random)
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Random background"))
Divider() Divider()
ColorPicker("", selection: $customWidget.bgOverlayColor) ColorPicker("", selection: $customWidget.bgOverlayColor)
.labelsHidden()
.accessibilityLabel(String(localized: "Background overlay color"))
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bgOverlay")) .accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bgOverlay"))
} }
.padding() .padding()
@@ -287,6 +299,7 @@ struct CreateWidgetView: View {
.onTapGesture(perform: { .onTapGesture(perform: {
showLeftEyeImagePicker.toggle() showLeftEyeImagePicker.toggle()
}) })
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor) .foregroundColor(textColor)
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
@@ -296,6 +309,7 @@ struct CreateWidgetView: View {
.onTapGesture(perform: { .onTapGesture(perform: {
showRightEyeImagePicker.toggle() showRightEyeImagePicker.toggle()
}) })
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
Divider() Divider()
@@ -304,6 +318,7 @@ struct CreateWidgetView: View {
.onTapGesture(perform: { .onTapGesture(perform: {
showMuthImagePicker.toggle() showMuthImagePicker.toggle()
}) })
.accessibilityAddTraits(.isButton)
.foregroundColor(textColor) .foregroundColor(textColor)
.foregroundColor(textColor) .foregroundColor(textColor)
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)

View File

@@ -492,6 +492,11 @@ struct VotingLayoutPickerCompact: View {
// MARK: - Celebration Animation Picker // MARK: - Celebration Animation Picker
struct CelebrationAnimationPickerCompact: View { struct CelebrationAnimationPickerCompact: View {
private enum AnimationConstants {
static let previewTriggerDelay: TimeInterval = 0.5
static let dismissTransitionDelay: TimeInterval = 0.35
}
@AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0 @AppStorage(UserDefaultsStore.Keys.celebrationAnimation.rawValue, store: GroupUserDefaults.groupDefaults) private var celebrationAnimationIndex: Int = 0
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -586,7 +591,7 @@ struct CelebrationAnimationPickerCompact: View {
// Auto-trigger the celebration after a brief pause // Auto-trigger the celebration after a brief pause
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .seconds(0.5)) try? await Task.sleep(for: .seconds(AnimationConstants.previewTriggerDelay))
guard previewAnimation == animation else { return } guard previewAnimation == animation else { return }
if hapticFeedbackEnabled { if hapticFeedbackEnabled {
HapticFeedbackManager.shared.play(for: animation) HapticFeedbackManager.shared.play(for: animation)
@@ -603,7 +608,7 @@ struct CelebrationAnimationPickerCompact: View {
previewOpacity = 0 previewOpacity = 0
} }
Task { @MainActor in Task { @MainActor in
try? await Task.sleep(for: .seconds(0.35)) try? await Task.sleep(for: .seconds(AnimationConstants.dismissTransitionDelay))
withAnimation(.easeOut(duration: 0.15)) { withAnimation(.easeOut(duration: 0.15)) {
previewAnimation = nil previewAnimation = nil
} }
@@ -879,7 +884,9 @@ struct SubscriptionBannerView: View {
do { do {
try await AppStore.showManageSubscriptions(in: windowScene) try await AppStore.showManageSubscriptions(in: windowScene)
} catch { } catch {
#if DEBUG
print("Failed to open subscription management: \(error)") print("Failed to open subscription management: \(error)")
#endif
} }
} }
} }

View File

@@ -69,8 +69,10 @@ struct IconPickerView: View {
ForEach(iconSets, id: \.self.0){ iconSet in ForEach(iconSets, id: \.self.0){ iconSet in
Button(action: { Button(action: {
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in UIApplication.shared.setAlternateIconName(iconSet.1) { error in
// FIXME: Handle error if let error {
AppLogger.settings.error("Failed to set app icon '\(iconSet.1)': \(error.localizedDescription)")
}
} }
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1)) AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
}, label: { }, label: {

View File

@@ -48,6 +48,8 @@ struct ImagePackPickerView: View {
imagePack = images imagePack = images
AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue)) AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue))
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(String(describing: images)) icon pack"))
if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 { if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 {
Divider() Divider()
} }

View File

@@ -47,6 +47,8 @@ struct PersonalityPackPickerView: View {
LocalNotification.rescheduleNotifiations() LocalNotification.rescheduleNotifiations()
// } // }
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(aPack.title()) personality pack"))
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0) // .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
.alert(isPresented: $showOver18Alert) { .alert(isPresented: $showOver18Alert) {
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) { let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {

View File

@@ -35,6 +35,8 @@ struct ShapePickerView: View {
.onTapGesture { .onTapGesture {
shapeRefreshToggleThing.toggle() shapeRefreshToggleThing.toggle()
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Refresh shapes"))
} }
} }
@@ -51,6 +53,8 @@ struct ShapePickerView: View {
shape = ashape shape = ashape
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue)) AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Select \(String(describing: ashape)) shape"))
.contentShape(Rectangle()) .contentShape(Rectangle())
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)

View File

@@ -72,7 +72,9 @@ class DayViewViewModel: ObservableObject {
public func update(entry: MoodEntryModel, toMood mood: Mood) { public func update(entry: MoodEntryModel, toMood mood: Mood) {
if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) { if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) {
#if DEBUG
print("Failed to update mood entry") print("Failed to update mood entry")
#endif
} }
} }

View File

@@ -110,10 +110,12 @@ struct EntryListView: View {
if hasNotes { if hasNotes {
Image(systemName: "note.text") Image(systemName: "note.text")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
} }
if hasReflection { if hasReflection {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.caption2) .font(.caption2)
.accessibilityHidden(true)
} }
} }
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -134,7 +136,10 @@ struct EntryListView: View {
if isMissing { if isMissing {
return String(localized: "\(dateString), no mood logged") return String(localized: "\(dateString), no mood logged")
} else { } else {
return "\(dateString), \(entry.mood.strValue)" var description = "\(dateString), \(entry.mood.strValue)"
if hasNotes { description += String(localized: ", has notes") }
if hasReflection { description += String(localized: ", has reflection") }
return description
} }
} }

View File

@@ -236,6 +236,8 @@ struct GuidedReflectionView: View {
.frame(height: 10) .frame(height: 10)
} }
} }
.accessibilityElement(children: .ignore)
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
} }
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots) .accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
} }

View File

@@ -13,6 +13,10 @@ enum InsightsTab: String, CaseIterable {
} }
struct InsightsView: View { struct InsightsView: View {
private enum AnimationConstants {
static let refreshDelay: UInt64 = 500_000_000 // 0.5 seconds in nanoseconds
}
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome @AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
@@ -40,6 +44,7 @@ struct InsightsView: View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.caption.weight(.medium)) .font(.caption.weight(.medium))
.accessibilityHidden(true)
Text("AI") Text("AI")
.font(.caption.weight(.semibold)) .font(.caption.weight(.semibold))
} }
@@ -148,7 +153,7 @@ struct InsightsView: View {
.refreshable { .refreshable {
viewModel.refreshInsights() viewModel.refreshInsights()
// Small delay to show refresh animation // Small delay to show refresh animation
try? await Task.sleep(nanoseconds: 500_000_000) try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
} }
.disabled(iapManager.shouldShowPaywall) .disabled(iapManager.shouldShowPaywall)
} }
@@ -173,6 +178,7 @@ struct InsightsView: View {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.font(.largeTitle) .font(.largeTitle)
.accessibilityHidden(true)
.foregroundStyle( .foregroundStyle(
LinearGradient( LinearGradient(
colors: [.purple, .blue], colors: [.purple, .blue],
@@ -202,6 +208,7 @@ struct InsightsView: View {
} label: { } label: {
HStack { HStack {
Image(systemName: "sparkles") Image(systemName: "sparkles")
.accessibilityHidden(true)
Text("Get Personal Insights") Text("Get Personal Insights")
} }
.font(.headline.weight(.bold)) .font(.headline.weight(.bold))

View File

@@ -1465,6 +1465,12 @@ struct GlassButton: View {
// MARK: - Main Lock Screen View // MARK: - Main Lock Screen View
struct LockScreenView: View { struct LockScreenView: View {
private enum AnimationConstants {
static let contentAppearDuration: TimeInterval = 0.8
static let contentAppearDelay: TimeInterval = 0.2
static let authenticationDelay: Int = 800 // milliseconds
}
@Environment(\.colorScheme) private var colorScheme @Environment(\.colorScheme) private var colorScheme
@ObservedObject var authManager: BiometricAuthManager @ObservedObject var authManager: BiometricAuthManager
@State private var showError = false @State private var showError = false
@@ -1714,13 +1720,13 @@ struct LockScreenView: View {
Text("Unable to verify your identity. Please try again.") Text("Unable to verify your identity. Please try again.")
} }
.onAppear { .onAppear {
withAnimation(.easeOut(duration: 0.8).delay(0.2)) { withAnimation(.easeOut(duration: AnimationConstants.contentAppearDuration).delay(AnimationConstants.contentAppearDelay)) {
showContent = true showContent = true
} }
if !authManager.isUnlocked && !authManager.isAuthenticating { if !authManager.isUnlocked && !authManager.isAuthenticating {
Task { Task {
try? await Task.sleep(for: .milliseconds(800)) try? await Task.sleep(for: .milliseconds(AnimationConstants.authenticationDelay))
await authManager.authenticate() await authManager.authenticate()
} }
} }

View File

@@ -63,6 +63,8 @@ struct MonthDetailView: View {
self.shareImage.showSheet = true self.shareImage.showSheet = true
self.shareImage.selectedShareImage = _image self.shareImage.selectedShareImage = _image
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Share month"))
} }
.background( .background(
theme.currentTheme.secondaryBGColor theme.currentTheme.secondaryBGColor
@@ -161,6 +163,7 @@ struct MonthDetailView: View {
showUpdateEntryAlert = true showUpdateEntryAlert = true
} }
}) })
.accessibilityAddTraits(.isButton)
.frame(minWidth: 0, maxWidth: .infinity) .frame(minWidth: 0, maxWidth: .infinity)
} }
} }

View File

@@ -10,6 +10,10 @@ import PhotosUI
struct NoteEditorView: View { struct NoteEditorView: View {
private enum AnimationConstants {
static let keyboardAppearDelay: TimeInterval = 0.5
}
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
} }
.padding() .padding()
} }
.navigationTitle("Journal Note") .navigationTitle(String(localized: "Journal Note"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .cancellationAction) { ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { Button(String(localized: "Cancel")) {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton) .accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
} }
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Save") { Button(String(localized: "Save")) {
saveNote() saveNote()
} }
.disabled(isSaving || noteText.count > maxCharacters) .disabled(isSaving || noteText.count > maxCharacters)
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
ToolbarItemGroup(placement: .keyboard) { ToolbarItemGroup(placement: .keyboard) {
Spacer() Spacer()
Button("Done") { Button(String(localized: "Done")) {
isTextFieldFocused = false isTextFieldFocused = false
} }
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton) .accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
} }
} }
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
isTextFieldFocused = true isTextFieldFocused = true
} }
} }
@@ -205,12 +209,12 @@ struct EntryDetailView: View {
.padding() .padding()
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.navigationTitle("Entry Details") .navigationTitle(String(localized: "Entry Details"))
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet) .accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
.toolbar { .toolbar {
ToolbarItem(placement: .confirmationAction) { ToolbarItem(placement: .confirmationAction) {
Button("Done") { Button(String(localized: "Done")) {
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
@@ -222,16 +226,16 @@ struct EntryDetailView: View {
.sheet(isPresented: $showReflectionFlow) { .sheet(isPresented: $showReflectionFlow) {
GuidedReflectionView(entry: entry) GuidedReflectionView(entry: entry)
} }
.alert("Delete Entry", isPresented: $showDeleteConfirmation) { .alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
Button("Delete", role: .destructive) { Button(String(localized: "Delete"), role: .destructive) {
onDelete() onDelete()
dismiss() dismiss()
} }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
Button("Cancel", role: .cancel) { } Button(String(localized: "Cancel"), role: .cancel) { }
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton) .accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
} message: { } message: {
Text("Are you sure you want to delete this mood entry? This cannot be undone.") Text(String(localized: "Are you sure you want to delete this mood entry? This cannot be undone."))
} }
.photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images) .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotoItem, matching: .images)
.onChange(of: selectedPhotoItem) { _, newItem in .onChange(of: selectedPhotoItem) { _, newItem in

View File

@@ -160,7 +160,9 @@ struct PhotoPickerView: View {
handleSelectedImage(image) handleSelectedImage(image)
} }
} catch { } catch {
#if DEBUG
print("PhotoPickerView: Failed to load image: \(error)") print("PhotoPickerView: Failed to load image: \(error)")
#endif
} }
} }

View File

@@ -26,6 +26,8 @@ struct SampleEntryView: View {
.onTapGesture { .onTapGesture {
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next) sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Refresh sample entry"))
} }
Spacer() Spacer()
}.padding() }.padding()

View File

@@ -324,7 +324,9 @@ struct LiveActivityRecordingView: View {
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
exportPath = outputDir.path exportPath = outputDir.path
#if DEBUG
print("📁 Exporting frames to: \(exportPath)") print("📁 Exporting frames to: \(exportPath)")
#endif
let target = targetStreak let target = targetStreak
let outDir = outputDir let outDir = outputDir
@@ -359,7 +361,9 @@ struct LiveActivityRecordingView: View {
await MainActor.run { await MainActor.run {
exportComplete = true exportComplete = true
#if DEBUG
print("✅ Export complete! \(target) frames saved to: \(outPath)") print("✅ Export complete! \(target) frames saved to: \(outPath)")
#endif
} }
} }
} }

View File

@@ -9,6 +9,10 @@ import SwiftUI
import UniformTypeIdentifiers import UniformTypeIdentifiers
import StoreKit import StoreKit
private enum SettingsAnimationConstants {
static let locationPermissionCheckDelay: TimeInterval = 1.0
}
// MARK: - Settings Content View (for use in SettingsTabView) // MARK: - Settings Content View (for use in SettingsTabView)
struct SettingsContentView: View { struct SettingsContentView: View {
@EnvironmentObject var authManager: BiometricAuthManager @EnvironmentObject var authManager: BiometricAuthManager
@@ -437,7 +441,9 @@ struct SettingsContentView: View {
widgetExportPath = await WidgetExporter.exportAllWidgets() widgetExportPath = await WidgetExporter.exportAllWidgets()
isExportingWidgets = false isExportingWidgets = false
if let path = widgetExportPath { if let path = widgetExportPath {
#if DEBUG
print("📸 Widgets exported to: \(path.path)") print("📸 Widgets exported to: \(path.path)")
#endif
openInFilesApp(path) openInFilesApp(path)
} }
} }
@@ -490,7 +496,9 @@ struct SettingsContentView: View {
votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts() votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts()
isExportingVotingLayouts = false isExportingVotingLayouts = false
if let path = votingLayoutExportPath { if let path = votingLayoutExportPath {
#if DEBUG
print("📸 Voting layouts exported to: \(path.path)") print("📸 Voting layouts exported to: \(path.path)")
#endif
openInFilesApp(path) openInFilesApp(path)
} }
} }
@@ -543,7 +551,9 @@ struct SettingsContentView: View {
watchExportPath = await WatchExporter.exportAllWatchViews() watchExportPath = await WatchExporter.exportAllWatchViews()
isExportingWatchViews = false isExportingWatchViews = false
if let path = watchExportPath { if let path = watchExportPath {
#if DEBUG
print("⌚ Watch views exported to: \(path.path)") print("⌚ Watch views exported to: \(path.path)")
#endif
openInFilesApp(path) openInFilesApp(path)
} }
} }
@@ -596,7 +606,9 @@ struct SettingsContentView: View {
insightsExportPath = await InsightsExporter.exportInsightsScreenshots() insightsExportPath = await InsightsExporter.exportInsightsScreenshots()
isExportingInsights = false isExportingInsights = false
if let path = insightsExportPath { if let path = insightsExportPath {
#if DEBUG
print("✨ Insights exported to: \(path.path)") print("✨ Insights exported to: \(path.path)")
#endif
openInFilesApp(path) openInFilesApp(path)
} }
} }
@@ -656,7 +668,9 @@ struct SettingsContentView: View {
sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots() sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
isGeneratingScreenshots = false isGeneratingScreenshots = false
if let path = sharingExportPath { if let path = sharingExportPath {
#if DEBUG
print("📸 Sharing screenshots exported to: \(path.path)") print("📸 Sharing screenshots exported to: \(path.path)")
#endif
openInFilesApp(path) openInFilesApp(path)
} }
} }
@@ -916,7 +930,9 @@ struct SettingsContentView: View {
AnalyticsManager.shared.track(.healthKitNotAuthorized) AnalyticsManager.shared.track(.healthKitNotAuthorized)
} }
} catch { } catch {
#if DEBUG
print("HealthKit authorization failed: \(error)") print("HealthKit authorization failed: \(error)")
#endif
AnalyticsManager.shared.track(.healthKitEnableFailed) AnalyticsManager.shared.track(.healthKitEnableFailed)
} }
} }
@@ -1014,7 +1030,7 @@ struct SettingsContentView: View {
LocationManager.shared.requestAuthorization() LocationManager.shared.requestAuthorization()
// Check if permission was denied after a brief delay // Check if permission was denied after a brief delay
Task { Task {
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
let status = LocationManager.shared.authorizationStatus let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted { if status == .denied || status == .restricted {
weatherEnabled = false weatherEnabled = false
@@ -1445,9 +1461,13 @@ struct SettingsView: View {
switch result { switch result {
case .success(let url): case .success(let url):
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0)) AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
#if DEBUG
print("Saved to \(url)") print("Saved to \(url)")
#endif
case .failure(let error): case .failure(let error):
#if DEBUG
print(error.localizedDescription) print(error.localizedDescription)
#endif
} }
}) })
.fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text], .fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text],
@@ -1488,8 +1508,10 @@ struct SettingsView: View {
} catch { } catch {
// Handle failure. // Handle failure.
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription)) AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
#if DEBUG
print("Unable to read file contents") print("Unable to read file contents")
print(error.localizedDescription) print(error.localizedDescription)
#endif
} }
} }
} }
@@ -1629,7 +1651,9 @@ struct SettingsView: View {
AnalyticsManager.shared.track(.healthKitNotAuthorized) AnalyticsManager.shared.track(.healthKitNotAuthorized)
} }
} catch { } catch {
#if DEBUG
print("HealthKit authorization failed: \(error)") print("HealthKit authorization failed: \(error)")
#endif
AnalyticsManager.shared.track(.healthKitEnableFailed) AnalyticsManager.shared.track(.healthKitEnableFailed)
} }
} }
@@ -1719,7 +1743,7 @@ struct SettingsView: View {
LocationManager.shared.requestAuthorization() LocationManager.shared.requestAuthorization()
// Check if permission was denied after a brief delay // Check if permission was denied after a brief delay
Task { Task {
try? await Task.sleep(for: .seconds(1)) try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
let status = LocationManager.shared.authorizationStatus let status = LocationManager.shared.authorizationStatus
if status == .denied || status == .restricted { if status == .denied || status == .restricted {
weatherEnabled = false weatherEnabled = false
@@ -2280,9 +2304,13 @@ struct SettingsView: View {
let url = URL(fileURLWithPath: path) let url = URL(fileURLWithPath: path)
do { do {
try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic) try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic)
#if DEBUG
print(url) print(url)
#endif
} catch { } catch {
#if DEBUG
print(error.localizedDescription) print(error.localizedDescription)
#endif
} }
} }

View File

@@ -101,6 +101,8 @@ struct SwitchableView: View {
self.headerTypeChanged(viewType) self.headerTypeChanged(viewType)
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType))) AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
} }
.accessibilityAddTraits(.isButton)
.accessibilityLabel(String(localized: "Switch header view"))
} }
} }