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:
@@ -22,8 +22,9 @@ struct ContentView: View {
|
||||
// Show voting UI
|
||||
VStack(spacing: 8) {
|
||||
Text("How do you feel?")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.font(.headline)
|
||||
.foregroundColor(.secondary)
|
||||
.accessibilityAddTraits(.isHeader)
|
||||
|
||||
// Top row: Great, Good, Average
|
||||
HStack(spacing: 8) {
|
||||
@@ -87,11 +88,14 @@ struct AlreadyRatedView: View {
|
||||
VStack(spacing: 12) {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 50))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
Text("Logged!")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "\(mood.strValue) mood logged"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +108,7 @@ struct MoodButton: View {
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(mood.watchEmoji)
|
||||
.font(.system(size: 28))
|
||||
.font(.title2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 50)
|
||||
.background(mood.watchColor.opacity(0.3))
|
||||
@@ -112,6 +116,8 @@ struct MoodButton: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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)"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,9 +24,12 @@ struct MoodStreakLiveActivity: Widget {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityHidden(true)
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.title2.bold())
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
|
||||
}
|
||||
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
@@ -34,6 +37,7 @@ struct MoodStreakLiveActivity: Widget {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
.font(.title2)
|
||||
.accessibilityLabel(String(localized: "Mood logged today"))
|
||||
} else {
|
||||
Text("Log now")
|
||||
.font(.caption)
|
||||
@@ -56,20 +60,25 @@ struct MoodStreakLiveActivity: Widget {
|
||||
Circle()
|
||||
.fill(Color(hex: context.state.lastMoodColor))
|
||||
.frame(width: 20, height: 20)
|
||||
.accessibilityHidden(true)
|
||||
Text("Today: \(context.state.lastMoodLogged)")
|
||||
.font(.subheadline)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityLabel(String(localized: "Streak"))
|
||||
} compactTrailing: {
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.caption.bold())
|
||||
.accessibilityLabel(String(localized: "\(context.state.currentStreak) days"))
|
||||
} minimal: {
|
||||
Image(systemName: "flame.fill")
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityLabel(String(localized: "Mood streak"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,12 +96,15 @@ struct MoodStreakLockScreenView: View {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.title)
|
||||
.foregroundColor(.orange)
|
||||
.accessibilityHidden(true)
|
||||
Text("\(context.state.currentStreak)")
|
||||
.font(.title.bold())
|
||||
Text("day streak")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(String(localized: "\(context.state.currentStreak) day streak"))
|
||||
|
||||
Divider()
|
||||
.frame(height: 50)
|
||||
@@ -104,6 +116,7 @@ struct MoodStreakLockScreenView: View {
|
||||
Circle()
|
||||
.fill(Color(hex: context.state.lastMoodColor))
|
||||
.frame(width: 24, height: 24)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading) {
|
||||
Text("Today's mood")
|
||||
.font(.caption)
|
||||
@@ -112,6 +125,7 @@ struct MoodStreakLockScreenView: View {
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
} else {
|
||||
VStack(alignment: .leading) {
|
||||
Text(context.state.currentStreak > 0 ? "Don't break your streak!" : "Start your streak!")
|
||||
|
||||
@@ -82,6 +82,8 @@ struct SmallWidgetView: View {
|
||||
return f
|
||||
}
|
||||
|
||||
private var isSampleData: Bool
|
||||
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = TimeLineCreator.createViews(daysBack: 2)
|
||||
@@ -89,6 +91,7 @@ struct SmallWidgetView: View {
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
}
|
||||
isSampleData = !hasRealData
|
||||
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)
|
||||
} else if let today = todayView {
|
||||
VStack(spacing: 0) {
|
||||
if isSampleData {
|
||||
Text(String(localized: "Log your first mood!"))
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Large mood icon
|
||||
@@ -152,6 +162,8 @@ struct MediumWidgetView: View {
|
||||
return f
|
||||
}
|
||||
|
||||
private var isSampleData: Bool
|
||||
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
|
||||
@@ -159,6 +171,7 @@ struct MediumWidgetView: View {
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
}
|
||||
isSampleData = !hasRealData
|
||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
|
||||
}
|
||||
|
||||
@@ -183,11 +196,19 @@ struct MediumWidgetView: View {
|
||||
Text("Last 5 Days")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(headerDateRange)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if isSampleData {
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(String(localized: "Log your first mood!"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("·")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(headerDateRange)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
@@ -264,6 +285,8 @@ struct LargeWidgetView: View {
|
||||
!entry.hasVotedToday
|
||||
}
|
||||
|
||||
private var isSampleData: Bool
|
||||
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
||||
@@ -271,6 +294,7 @@ struct LargeWidgetView: View {
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
}
|
||||
isSampleData = !hasRealData
|
||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
|
||||
}
|
||||
|
||||
@@ -301,7 +325,7 @@ struct LargeWidgetView: View {
|
||||
Text("Last 10 Days")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(headerDateRange)
|
||||
Text(isSampleData ? String(localized: "Log your first mood!") : headerDateRange)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -158,10 +158,13 @@ struct VotedStatsView: View {
|
||||
Circle()
|
||||
.fill(moodTint.color(forMood: mood))
|
||||
.frame(width: 8, height: 8)
|
||||
.accessibilityHidden(true)
|
||||
Text("\(count)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel("\(count) \(mood.strValue)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,8 @@ struct VotingView: View {
|
||||
VStack(spacing: 0) {
|
||||
// Top 50%: Text left-aligned, vertically centered
|
||||
HStack {
|
||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
|
||||
.font(.title3.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
@@ -159,8 +159,8 @@ struct LargeVotingView: View {
|
||||
GeometryReader { geo in
|
||||
VStack(spacing: 0) {
|
||||
// Top 33%: Title centered
|
||||
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
Text(hasSubscription ? promptText : String(localized: "Subscribe to track your mood"))
|
||||
.font(.title2.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
|
||||
@@ -51,7 +51,9 @@ class BGTask {
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Could not schedule weather retry: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +69,9 @@ class BGTask {
|
||||
do {
|
||||
try BGTaskScheduler.shared.submit(request)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Could not schedule image fetch: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,6 @@ extension Color {
|
||||
}
|
||||
|
||||
extension Color: @retroactive RawRepresentable {
|
||||
// TODO: Sort out alpha
|
||||
public init?(rawValue: Int) {
|
||||
let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF
|
||||
let green = Double((rawValue & 0x00FF00) >> 8) / 0xFF
|
||||
|
||||
@@ -307,8 +307,16 @@ class IAPManager: ObservableObject {
|
||||
|
||||
// Get renewal info
|
||||
if let product = currentProduct,
|
||||
let subscription = product.subscription,
|
||||
let statuses = try? await subscription.status {
|
||||
let subscription = product.subscription {
|
||||
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
|
||||
|
||||
for status in statuses {
|
||||
|
||||
@@ -69,11 +69,15 @@ class LocalNotification {
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: notificationContent, trigger: trigger)
|
||||
UNUserNotificationCenter.current().add(request) { (error : Error?) in
|
||||
if let theError = error {
|
||||
#if DEBUG
|
||||
print(theError.localizedDescription)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
print(error)
|
||||
#endif
|
||||
// Todo: show enable this
|
||||
break
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import os.log
|
||||
|
||||
enum VotingLayoutStyle: Int, CaseIterable {
|
||||
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 {
|
||||
enum Keys: String {
|
||||
case savedOnboardingData
|
||||
@@ -226,15 +229,18 @@ class UserDefaultsStore {
|
||||
}
|
||||
|
||||
// Decode and cache
|
||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data,
|
||||
let model = try? JSONDecoder().decode(OnboardingData.self, from: data) {
|
||||
cachedOnboardingData = model
|
||||
return model
|
||||
} else {
|
||||
let defaultData = OnboardingData()
|
||||
cachedOnboardingData = defaultData
|
||||
return defaultData
|
||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) as? Data {
|
||||
do {
|
||||
let model = try JSONDecoder().decode(OnboardingData.self, from: data)
|
||||
cachedOnboardingData = model
|
||||
return model
|
||||
} catch {
|
||||
userDefaultsLogger.error("Failed to decode onboarding data: \(error)")
|
||||
}
|
||||
}
|
||||
let defaultData = OnboardingData()
|
||||
cachedOnboardingData = defaultData
|
||||
return defaultData
|
||||
}
|
||||
|
||||
/// Invalidate cached onboarding data (call when data might have changed externally)
|
||||
@@ -251,7 +257,7 @@ class UserDefaultsStore {
|
||||
let data = try JSONEncoder().encode(onboardingData)
|
||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue)
|
||||
} catch {
|
||||
print("Error saving onboarding: \(error)")
|
||||
userDefaultsLogger.error("Failed to encode onboarding data: \(error)")
|
||||
}
|
||||
|
||||
// Re-cache the saved data
|
||||
@@ -314,28 +320,38 @@ class UserDefaultsStore {
|
||||
}
|
||||
|
||||
static func getCustomWidgets() -> [CustomWidgetModel] {
|
||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data,
|
||||
let model = try? JSONDecoder().decode([CustomWidgetModel].self, from: data) {
|
||||
return model
|
||||
} else {
|
||||
GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||
|
||||
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
|
||||
if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data {
|
||||
do {
|
||||
let model = try JSONDecoder().decode([CustomWidgetModel].self, from: data)
|
||||
return model
|
||||
} catch {
|
||||
userDefaultsLogger.error("Failed to decode custom widgets: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -366,7 +382,7 @@ class UserDefaultsStore {
|
||||
let data = try JSONEncoder().encode(existingWidgets)
|
||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||
} catch {
|
||||
print("Error saving custom widget: \(error)")
|
||||
userDefaultsLogger.error("Failed to encode custom widget for save: \(error)")
|
||||
}
|
||||
return UserDefaultsStore.getCustomWidgets()
|
||||
}
|
||||
@@ -396,7 +412,7 @@ class UserDefaultsStore {
|
||||
let data = try JSONEncoder().encode(existingWidgets)
|
||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue)
|
||||
} catch {
|
||||
print("Error deleting custom widget: \(error)")
|
||||
userDefaultsLogger.error("Failed to encode custom widgets for delete: \(error)")
|
||||
}
|
||||
return UserDefaultsStore.getCustomWidgets()
|
||||
}
|
||||
@@ -407,7 +423,7 @@ class UserDefaultsStore {
|
||||
let model = try JSONDecoder().decode(SavedMoodTint.self, from: data)
|
||||
return model
|
||||
} catch {
|
||||
print(error)
|
||||
userDefaultsLogger.error("Failed to decode custom mood tint: \(error)")
|
||||
}
|
||||
}
|
||||
return SavedMoodTint()
|
||||
@@ -428,7 +444,7 @@ class UserDefaultsStore {
|
||||
let data = try JSONEncoder().encode(customTint)
|
||||
GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue)
|
||||
} catch {
|
||||
print("Error saving custom mood tint: \(error)")
|
||||
userDefaultsLogger.error("Failed to encode custom mood tint: \(error)")
|
||||
}
|
||||
return UserDefaultsStore.getCustomMoodTint()
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ class LiveActivityManager: ObservableObject {
|
||||
// Start a mood streak Live Activity
|
||||
func startStreakActivity(streak: Int, lastMood: Mood?, hasLoggedToday: Bool) {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
#if DEBUG
|
||||
print("Live Activities not enabled")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
@@ -76,7 +78,9 @@ class LiveActivityManager: ObservableObject {
|
||||
)
|
||||
currentActivity = activity
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Error starting Live Activity: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,23 +261,31 @@ class LiveActivityScheduler: ObservableObject {
|
||||
invalidateTimers()
|
||||
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
#if DEBUG
|
||||
print("[LiveActivity] Live Activities not enabled by user")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
guard let startTime = getStartTime(),
|
||||
let endTime = getEndTime() else {
|
||||
#if DEBUG
|
||||
print("[LiveActivity] No rating time configured - skipping")
|
||||
#endif
|
||||
return
|
||||
}
|
||||
|
||||
let hasRated = hasRatedToday()
|
||||
#if DEBUG
|
||||
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 hasRated {
|
||||
#if DEBUG
|
||||
print("[LiveActivity] User already rated today - scheduling for next day")
|
||||
#endif
|
||||
scheduleForNextDay()
|
||||
return
|
||||
}
|
||||
@@ -281,7 +293,9 @@ class LiveActivityScheduler: ObservableObject {
|
||||
// Check if we're within the activity window (rating time to 5 hrs after)
|
||||
if now >= startTime && now <= endTime {
|
||||
// Start activity immediately
|
||||
#if DEBUG
|
||||
print("[LiveActivity] Within window - starting activity now")
|
||||
#endif
|
||||
let streak = calculateStreak()
|
||||
LiveActivityManager.shared.startStreakActivity(streak: streak, lastMood: getTodaysMood(), hasLoggedToday: false)
|
||||
|
||||
@@ -289,12 +303,16 @@ class LiveActivityScheduler: ObservableObject {
|
||||
scheduleEnd(at: endTime)
|
||||
} else if now < startTime {
|
||||
// Schedule start for later today
|
||||
#if DEBUG
|
||||
print("[LiveActivity] Before window - scheduling start for \(startTime)")
|
||||
#endif
|
||||
scheduleStart(at: startTime)
|
||||
scheduleEnd(at: endTime)
|
||||
} else {
|
||||
// Past the window for today, schedule for tomorrow
|
||||
#if DEBUG
|
||||
print("[LiveActivity] Past window - scheduling for tomorrow")
|
||||
#endif
|
||||
scheduleForNextDay()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ import WidgetKit
|
||||
|
||||
@main
|
||||
struct ReflectApp: App {
|
||||
private enum AnimationConstants {
|
||||
static let deepLinkHandlingDelay: TimeInterval = 0.3
|
||||
}
|
||||
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
@@ -83,7 +87,7 @@ struct ReflectApp: App {
|
||||
}
|
||||
if let url = AppDelegate.pendingDeepLinkURL {
|
||||
AppDelegate.pendingDeepLinkURL = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.deepLinkHandlingDelay) {
|
||||
handleDeepLink(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,6 +238,10 @@ class ReflectTipsManager: ObservableObject {
|
||||
// MARK: - View Modifier for Easy Integration
|
||||
|
||||
struct ReflectTipModifier: ViewModifier {
|
||||
private enum AnimationConstants {
|
||||
static let tipPresentationDelay: TimeInterval = 0.5
|
||||
}
|
||||
|
||||
let tip: any ReflectTip
|
||||
let gradientColors: [Color]
|
||||
|
||||
@@ -254,7 +258,7 @@ struct ReflectTipModifier: ViewModifier {
|
||||
|
||||
// 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) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.tipPresentationDelay) {
|
||||
if ReflectTipsManager.shared.shouldShowTip(tip) {
|
||||
showSheet = true
|
||||
}
|
||||
|
||||
@@ -102,7 +102,9 @@ class BiometricAuthManager: ObservableObject {
|
||||
}
|
||||
return success
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Authentication failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription))
|
||||
|
||||
// If biometrics failed, try device passcode as fallback
|
||||
@@ -126,7 +128,9 @@ class BiometricAuthManager: ObservableObject {
|
||||
isUnlocked = success
|
||||
return success
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Passcode authentication failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -146,7 +150,9 @@ class BiometricAuthManager: ObservableObject {
|
||||
|
||||
// Only allow enabling if biometrics are available
|
||||
guard context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error) else {
|
||||
#if DEBUG
|
||||
print("Biometric authentication not available: \(error?.localizedDescription ?? "Unknown")")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -164,7 +170,9 @@ class BiometricAuthManager: ObservableObject {
|
||||
|
||||
return success
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to enable lock: \(error.localizedDescription)")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,9 @@ class ExportService {
|
||||
trackDataExported(format: "csv", count: entries.count)
|
||||
return tempURL
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("ExportService: Failed to write CSV: \(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -177,7 +179,9 @@ class ExportService {
|
||||
try data.write(to: tempURL)
|
||||
return tempURL
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("ExportService: Failed to write PDF: \(error)")
|
||||
#endif
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import Foundation
|
||||
import FoundationModels
|
||||
import os.log
|
||||
|
||||
/// Error types for insight generation
|
||||
enum InsightGenerationError: Error, LocalizedError {
|
||||
@@ -244,9 +245,7 @@ class FoundationModelsInsightService: ObservableObject {
|
||||
return insights
|
||||
} catch {
|
||||
// Log detailed error for debugging
|
||||
print("❌ AI Insight generation failed for '\(periodName)': \(error)")
|
||||
print(" Error type: \(type(of: error))")
|
||||
print(" Localized: \(error.localizedDescription)")
|
||||
AppLogger.ai.error("AI Insight generation failed for '\(periodName)': \(error)")
|
||||
|
||||
lastError = .generationFailed(underlying: error)
|
||||
throw lastError!
|
||||
|
||||
@@ -71,7 +71,9 @@ class HealthService: ObservableObject {
|
||||
|
||||
func requestAuthorization() async -> Bool {
|
||||
guard isAvailable else {
|
||||
#if DEBUG
|
||||
print("HealthService: HealthKit not available on this device")
|
||||
#endif
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -82,7 +84,9 @@ class HealthService: ObservableObject {
|
||||
AnalyticsManager.shared.track(.healthKitAuthorized)
|
||||
return true
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("HealthService: Authorization failed: \(error.localizedDescription)")
|
||||
#endif
|
||||
AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription))
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
/// Exports insights view screenshots for App Store marketing
|
||||
@MainActor
|
||||
@@ -28,7 +29,12 @@ class InsightsExporter {
|
||||
|
||||
// Clean and create export directory
|
||||
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
|
||||
|
||||
@@ -95,7 +101,11 @@ class InsightsExporter {
|
||||
if let image = renderer.uiImage {
|
||||
let url = folder.appendingPathComponent("\(name).png")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ class PhotoManager: ObservableObject {
|
||||
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
||||
if let thumbnail = createThumbnail(from: image),
|
||||
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)
|
||||
@@ -107,13 +111,21 @@ class PhotoManager: ObservableObject {
|
||||
let filename = "\(id.uuidString).jpg"
|
||||
let fullURL = photosDir.appendingPathComponent(filename)
|
||||
|
||||
guard FileManager.default.fileExists(atPath: fullURL.path),
|
||||
let data = try? Data(contentsOf: fullURL),
|
||||
let image = UIImage(data: data) else {
|
||||
guard FileManager.default.fileExists(atPath: fullURL.path) else {
|
||||
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? {
|
||||
@@ -123,10 +135,15 @@ class PhotoManager: ObservableObject {
|
||||
let thumbnailURL = thumbnailsDir.appendingPathComponent(filename)
|
||||
|
||||
// Try thumbnail first
|
||||
if FileManager.default.fileExists(atPath: thumbnailURL.path),
|
||||
let data = try? Data(contentsOf: thumbnailURL),
|
||||
let image = UIImage(data: data) {
|
||||
return image
|
||||
if FileManager.default.fileExists(atPath: thumbnailURL.path) {
|
||||
do {
|
||||
let data = try Data(contentsOf: thumbnailURL)
|
||||
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
|
||||
@@ -159,7 +176,11 @@ class PhotoManager: ObservableObject {
|
||||
|
||||
// Delete thumbnail
|
||||
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 {
|
||||
@@ -197,8 +218,13 @@ class PhotoManager: ObservableObject {
|
||||
var totalPhotoCount: Int {
|
||||
guard let photosDir = photosDirectory else { return 0 }
|
||||
|
||||
let files = try? FileManager.default.contentsOfDirectory(atPath: photosDir.path)
|
||||
return files?.filter { $0.hasSuffix(".jpg") }.count ?? 0
|
||||
do {
|
||||
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 {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
/// Exports sharing template screenshots for App Store marketing
|
||||
@MainActor
|
||||
@@ -21,13 +22,23 @@ class SharingScreenshotExporter {
|
||||
|
||||
// Clean and create export directory
|
||||
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
|
||||
let origDir = exportPath.appendingPathComponent("originals", isDirectory: true)
|
||||
let varDir = exportPath.appendingPathComponent("variations", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: origDir, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: varDir, withIntermediateDirectories: true)
|
||||
do {
|
||||
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
|
||||
let distantPast = Date(timeIntervalSince1970: 0)
|
||||
@@ -167,7 +178,7 @@ class SharingScreenshotExporter {
|
||||
try data.write(to: url)
|
||||
return true
|
||||
} catch {
|
||||
print("Failed to save \(name): \(error)")
|
||||
AppLogger.export.error("Failed to save sharing screenshot '\(name)': \(error)")
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
/// Exports watch view previews to PNG files for App Store screenshots
|
||||
@MainActor
|
||||
@@ -76,7 +77,12 @@ class WatchExporter {
|
||||
|
||||
// Clean and create export directory
|
||||
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
|
||||
|
||||
@@ -85,7 +91,12 @@ class WatchExporter {
|
||||
for iconOption in allIcons {
|
||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||
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(
|
||||
moodTint: tintOption.tint,
|
||||
@@ -242,7 +253,11 @@ class WatchExporter {
|
||||
if let image = renderer.uiImage {
|
||||
let url = folder.appendingPathComponent("\(name).png")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#if DEBUG
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import os.log
|
||||
|
||||
/// Exports widget previews to PNG files for App Store screenshots
|
||||
@MainActor
|
||||
@@ -76,7 +77,12 @@ class WidgetExporter {
|
||||
|
||||
// Clean and create export directory
|
||||
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
|
||||
|
||||
@@ -85,7 +91,12 @@ class WidgetExporter {
|
||||
for iconOption in allIcons {
|
||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||
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(
|
||||
moodTint: tintOption.tint,
|
||||
@@ -155,7 +166,12 @@ class WidgetExporter {
|
||||
let exportPath = documentsPath.appendingPathComponent("WidgetExports_Current", isDirectory: true)
|
||||
|
||||
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(
|
||||
moodTint: UserDefaultsStore.moodTintable(),
|
||||
@@ -177,7 +193,12 @@ class WidgetExporter {
|
||||
|
||||
// Clean and create export directory
|
||||
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
|
||||
|
||||
@@ -186,7 +207,12 @@ class WidgetExporter {
|
||||
for iconOption in allIcons {
|
||||
let folderName = "\(tintOption.name)_\(iconOption.name)"
|
||||
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(
|
||||
moodTint: tintOption.tint,
|
||||
@@ -372,7 +398,11 @@ class WidgetExporter {
|
||||
if let image = renderer.uiImage {
|
||||
let url = folder.appendingPathComponent("\(name).png")
|
||||
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 {
|
||||
let url = folder.appendingPathComponent("\(name).png")
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +305,12 @@ struct FlipRevealAnimation: View {
|
||||
struct ShatterReformAnimation: View {
|
||||
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 shardRotations: [Double] = []
|
||||
@State private var shardOpacities: [Double] = []
|
||||
@@ -354,7 +360,7 @@ struct ShatterReformAnimation: View {
|
||||
|
||||
// Phase 2: Converge to center and fade
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(0.6))
|
||||
try? await Task.sleep(for: .seconds(AnimationConstants.shatterPhaseDuration))
|
||||
phase = .reform
|
||||
withAnimation(.easeInOut(duration: 0.5)) {
|
||||
for i in 0..<shardCount {
|
||||
@@ -367,7 +373,7 @@ struct ShatterReformAnimation: View {
|
||||
|
||||
// Phase 3: Show checkmark
|
||||
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)) {
|
||||
checkmarkOpacity = 1
|
||||
}
|
||||
@@ -375,7 +381,7 @@ struct ShatterReformAnimation: View {
|
||||
|
||||
// Phase 4: Fade out
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(1.8))
|
||||
try? await Task.sleep(for: .seconds(AnimationConstants.fadeOutDelay))
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
checkmarkOpacity = 0
|
||||
}
|
||||
|
||||
@@ -182,6 +182,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "background"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_background_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bg"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -193,6 +194,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_inner_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("inner"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -204,6 +206,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_face_outline_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("stroke"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -217,6 +220,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_view_left_eye_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("leftEye"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -228,6 +232,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_view_right_eye_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("rightEye"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -239,6 +244,7 @@ struct CreateWidgetView: View {
|
||||
AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth"))
|
||||
}
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "create_widget_view_mouth_color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("mouth"))
|
||||
}
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -264,14 +270,20 @@ struct CreateWidgetView: View {
|
||||
.onTapGesture {
|
||||
update(background: bg)
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Select background \(bg.rawValue)"))
|
||||
}
|
||||
mixBG
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.randomBackgroundButton)
|
||||
.onTapGesture {
|
||||
update(background: .random)
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Random background"))
|
||||
Divider()
|
||||
ColorPicker("", selection: $customWidget.bgOverlayColor)
|
||||
.labelsHidden()
|
||||
.accessibilityLabel(String(localized: "Background overlay color"))
|
||||
.accessibilityIdentifier(AccessibilityID.CustomWidget.colorPicker("bgOverlay"))
|
||||
}
|
||||
.padding()
|
||||
@@ -287,6 +299,7 @@ struct CreateWidgetView: View {
|
||||
.onTapGesture(perform: {
|
||||
showLeftEyeImagePicker.toggle()
|
||||
})
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.foregroundColor(textColor)
|
||||
.foregroundColor(textColor)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
@@ -296,6 +309,7 @@ struct CreateWidgetView: View {
|
||||
.onTapGesture(perform: {
|
||||
showRightEyeImagePicker.toggle()
|
||||
})
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.foregroundColor(textColor)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
Divider()
|
||||
@@ -304,6 +318,7 @@ struct CreateWidgetView: View {
|
||||
.onTapGesture(perform: {
|
||||
showMuthImagePicker.toggle()
|
||||
})
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.foregroundColor(textColor)
|
||||
.foregroundColor(textColor)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
|
||||
@@ -492,6 +492,11 @@ struct VotingLayoutPickerCompact: View {
|
||||
|
||||
// MARK: - Celebration Animation Picker
|
||||
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.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||
@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
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(0.5))
|
||||
try? await Task.sleep(for: .seconds(AnimationConstants.previewTriggerDelay))
|
||||
guard previewAnimation == animation else { return }
|
||||
if hapticFeedbackEnabled {
|
||||
HapticFeedbackManager.shared.play(for: animation)
|
||||
@@ -603,7 +608,7 @@ struct CelebrationAnimationPickerCompact: View {
|
||||
previewOpacity = 0
|
||||
}
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(for: .seconds(0.35))
|
||||
try? await Task.sleep(for: .seconds(AnimationConstants.dismissTransitionDelay))
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
previewAnimation = nil
|
||||
}
|
||||
@@ -879,7 +884,9 @@ struct SubscriptionBannerView: View {
|
||||
do {
|
||||
try await AppStore.showManageSubscriptions(in: windowScene)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to open subscription management: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,8 +69,10 @@ struct IconPickerView: View {
|
||||
|
||||
ForEach(iconSets, id: \.self.0){ iconSet in
|
||||
Button(action: {
|
||||
UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in
|
||||
// FIXME: Handle error
|
||||
UIApplication.shared.setAlternateIconName(iconSet.1) { error in
|
||||
if let error {
|
||||
AppLogger.settings.error("Failed to set app icon '\(iconSet.1)': \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1))
|
||||
}, label: {
|
||||
|
||||
@@ -48,6 +48,8 @@ struct ImagePackPickerView: View {
|
||||
imagePack = images
|
||||
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 {
|
||||
Divider()
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ struct PersonalityPackPickerView: View {
|
||||
LocalNotification.rescheduleNotifiations()
|
||||
// }
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Select \(aPack.title()) personality pack"))
|
||||
// .blur(radius: aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW ? 5 : 0)
|
||||
.alert(isPresented: $showOver18Alert) {
|
||||
let primaryButton = Alert.Button.default(Text(String(localized: "customize_view_over18alert_ok"))) {
|
||||
|
||||
@@ -35,6 +35,8 @@ struct ShapePickerView: View {
|
||||
.onTapGesture {
|
||||
shapeRefreshToggleThing.toggle()
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Refresh shapes"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +53,8 @@ struct ShapePickerView: View {
|
||||
shape = ashape
|
||||
AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue))
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Select \(String(describing: ashape)) shape"))
|
||||
.contentShape(Rectangle())
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
|
||||
@@ -72,7 +72,9 @@ class DayViewViewModel: ObservableObject {
|
||||
|
||||
public func update(entry: MoodEntryModel, toMood mood: Mood) {
|
||||
if !MoodLogger.shared.updateMood(entryDate: entry.forDate, withMood: mood) {
|
||||
#if DEBUG
|
||||
print("Failed to update mood entry")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,10 +110,12 @@ struct EntryListView: View {
|
||||
if hasNotes {
|
||||
Image(systemName: "note.text")
|
||||
.font(.caption2)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
if hasReflection {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.caption2)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -134,7 +136,10 @@ struct EntryListView: View {
|
||||
if isMissing {
|
||||
return String(localized: "\(dateString), no mood logged")
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,8 @@ struct GuidedReflectionView: View {
|
||||
.frame(height: 10)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(String(localized: "\(draft.steps.filter(\.hasAnswer).count) of \(draft.steps.count) steps completed"))
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.GuidedReflection.progressDots)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ enum InsightsTab: String, CaseIterable {
|
||||
}
|
||||
|
||||
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.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default
|
||||
@AppStorage(UserDefaultsStore.Keys.moodImages.rawValue, store: GroupUserDefaults.groupDefaults) private var imagePack: MoodImages = .FontAwesome
|
||||
@@ -40,6 +44,7 @@ struct InsightsView: View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sparkles")
|
||||
.font(.caption.weight(.medium))
|
||||
.accessibilityHidden(true)
|
||||
Text("AI")
|
||||
.font(.caption.weight(.semibold))
|
||||
}
|
||||
@@ -148,7 +153,7 @@ struct InsightsView: View {
|
||||
.refreshable {
|
||||
viewModel.refreshInsights()
|
||||
// Small delay to show refresh animation
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
try? await Task.sleep(nanoseconds: AnimationConstants.refreshDelay)
|
||||
}
|
||||
.disabled(iapManager.shouldShowPaywall)
|
||||
}
|
||||
@@ -173,6 +178,7 @@ struct InsightsView: View {
|
||||
|
||||
Image(systemName: "sparkles")
|
||||
.font(.largeTitle)
|
||||
.accessibilityHidden(true)
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.purple, .blue],
|
||||
@@ -202,6 +208,7 @@ struct InsightsView: View {
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "sparkles")
|
||||
.accessibilityHidden(true)
|
||||
Text("Get Personal Insights")
|
||||
}
|
||||
.font(.headline.weight(.bold))
|
||||
|
||||
@@ -1465,6 +1465,12 @@ struct GlassButton: View {
|
||||
// MARK: - Main Lock Screen 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
|
||||
@ObservedObject var authManager: BiometricAuthManager
|
||||
@State private var showError = false
|
||||
@@ -1714,13 +1720,13 @@ struct LockScreenView: View {
|
||||
Text("Unable to verify your identity. Please try again.")
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeOut(duration: 0.8).delay(0.2)) {
|
||||
withAnimation(.easeOut(duration: AnimationConstants.contentAppearDuration).delay(AnimationConstants.contentAppearDelay)) {
|
||||
showContent = true
|
||||
}
|
||||
|
||||
if !authManager.isUnlocked && !authManager.isAuthenticating {
|
||||
Task {
|
||||
try? await Task.sleep(for: .milliseconds(800))
|
||||
try? await Task.sleep(for: .milliseconds(AnimationConstants.authenticationDelay))
|
||||
await authManager.authenticate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,13 @@ struct MonthDetailView: View {
|
||||
.onTapGesture {
|
||||
let impactMed = UIImpactFeedbackGenerator(style: .heavy)
|
||||
impactMed.impactOccurred()
|
||||
|
||||
|
||||
let _image = self.image
|
||||
self.shareImage.showSheet = true
|
||||
self.shareImage.selectedShareImage = _image
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Share month"))
|
||||
}
|
||||
.background(
|
||||
theme.currentTheme.secondaryBGColor
|
||||
@@ -161,6 +163,7 @@ struct MonthDetailView: View {
|
||||
showUpdateEntryAlert = true
|
||||
}
|
||||
})
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ import PhotosUI
|
||||
|
||||
struct NoteEditorView: View {
|
||||
|
||||
private enum AnimationConstants {
|
||||
static let keyboardAppearDelay: TimeInterval = 0.5
|
||||
}
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system
|
||||
|
||||
@@ -57,18 +61,18 @@ struct NoteEditorView: View {
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("Journal Note")
|
||||
.navigationTitle(String(localized: "Journal Note"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
Button(String(localized: "Cancel")) {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.NoteEditor.cancelButton)
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
Button(String(localized: "Save")) {
|
||||
saveNote()
|
||||
}
|
||||
.disabled(isSaving || noteText.count > maxCharacters)
|
||||
@@ -78,14 +82,14 @@ struct NoteEditorView: View {
|
||||
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
Button(String(localized: "Done")) {
|
||||
isTextFieldFocused = false
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.NoteEditor.keyboardDoneButton)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + AnimationConstants.keyboardAppearDelay) {
|
||||
isTextFieldFocused = true
|
||||
}
|
||||
}
|
||||
@@ -205,12 +209,12 @@ struct EntryDetailView: View {
|
||||
.padding()
|
||||
}
|
||||
.background(Color(.systemGroupedBackground))
|
||||
.navigationTitle("Entry Details")
|
||||
.navigationTitle(String(localized: "Entry Details"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.sheet)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
Button(String(localized: "Done")) {
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.doneButton)
|
||||
@@ -222,16 +226,16 @@ struct EntryDetailView: View {
|
||||
.sheet(isPresented: $showReflectionFlow) {
|
||||
GuidedReflectionView(entry: entry)
|
||||
}
|
||||
.alert("Delete Entry", isPresented: $showDeleteConfirmation) {
|
||||
Button("Delete", role: .destructive) {
|
||||
.alert(String(localized: "Delete Entry"), isPresented: $showDeleteConfirmation) {
|
||||
Button(String(localized: "Delete"), role: .destructive) {
|
||||
onDelete()
|
||||
dismiss()
|
||||
}
|
||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteConfirmButton)
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button(String(localized: "Cancel"), role: .cancel) { }
|
||||
.accessibilityIdentifier(AccessibilityID.EntryDetail.deleteCancelButton)
|
||||
} 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)
|
||||
.onChange(of: selectedPhotoItem) { _, newItem in
|
||||
|
||||
@@ -160,7 +160,9 @@ struct PhotoPickerView: View {
|
||||
handleSelectedImage(image)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("PhotoPickerView: Failed to load image: \(error)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ struct SampleEntryView: View {
|
||||
.onTapGesture {
|
||||
sampleListEntry = DataController.shared.generateObjectNotInArray(forDate: Date(), withMood: sampleListEntry.mood.next)
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Refresh sample entry"))
|
||||
}
|
||||
Spacer()
|
||||
}.padding()
|
||||
|
||||
@@ -324,7 +324,9 @@ struct LiveActivityRecordingView: View {
|
||||
try? FileManager.default.createDirectory(at: outputDir, withIntermediateDirectories: true)
|
||||
|
||||
exportPath = outputDir.path
|
||||
#if DEBUG
|
||||
print("📁 Exporting frames to: \(exportPath)")
|
||||
#endif
|
||||
|
||||
let target = targetStreak
|
||||
let outDir = outputDir
|
||||
@@ -359,7 +361,9 @@ struct LiveActivityRecordingView: View {
|
||||
|
||||
await MainActor.run {
|
||||
exportComplete = true
|
||||
#if DEBUG
|
||||
print("✅ Export complete! \(target) frames saved to: \(outPath)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import StoreKit
|
||||
|
||||
private enum SettingsAnimationConstants {
|
||||
static let locationPermissionCheckDelay: TimeInterval = 1.0
|
||||
}
|
||||
|
||||
// MARK: - Settings Content View (for use in SettingsTabView)
|
||||
struct SettingsContentView: View {
|
||||
@EnvironmentObject var authManager: BiometricAuthManager
|
||||
@@ -437,7 +441,9 @@ struct SettingsContentView: View {
|
||||
widgetExportPath = await WidgetExporter.exportAllWidgets()
|
||||
isExportingWidgets = false
|
||||
if let path = widgetExportPath {
|
||||
#if DEBUG
|
||||
print("📸 Widgets exported to: \(path.path)")
|
||||
#endif
|
||||
openInFilesApp(path)
|
||||
}
|
||||
}
|
||||
@@ -490,7 +496,9 @@ struct SettingsContentView: View {
|
||||
votingLayoutExportPath = await WidgetExporter.exportAllVotingLayouts()
|
||||
isExportingVotingLayouts = false
|
||||
if let path = votingLayoutExportPath {
|
||||
#if DEBUG
|
||||
print("📸 Voting layouts exported to: \(path.path)")
|
||||
#endif
|
||||
openInFilesApp(path)
|
||||
}
|
||||
}
|
||||
@@ -543,7 +551,9 @@ struct SettingsContentView: View {
|
||||
watchExportPath = await WatchExporter.exportAllWatchViews()
|
||||
isExportingWatchViews = false
|
||||
if let path = watchExportPath {
|
||||
#if DEBUG
|
||||
print("⌚ Watch views exported to: \(path.path)")
|
||||
#endif
|
||||
openInFilesApp(path)
|
||||
}
|
||||
}
|
||||
@@ -596,7 +606,9 @@ struct SettingsContentView: View {
|
||||
insightsExportPath = await InsightsExporter.exportInsightsScreenshots()
|
||||
isExportingInsights = false
|
||||
if let path = insightsExportPath {
|
||||
#if DEBUG
|
||||
print("✨ Insights exported to: \(path.path)")
|
||||
#endif
|
||||
openInFilesApp(path)
|
||||
}
|
||||
}
|
||||
@@ -656,7 +668,9 @@ struct SettingsContentView: View {
|
||||
sharingExportPath = await SharingScreenshotExporter.exportAllSharingScreenshots()
|
||||
isGeneratingScreenshots = false
|
||||
if let path = sharingExportPath {
|
||||
#if DEBUG
|
||||
print("📸 Sharing screenshots exported to: \(path.path)")
|
||||
#endif
|
||||
openInFilesApp(path)
|
||||
}
|
||||
}
|
||||
@@ -916,7 +930,9 @@ struct SettingsContentView: View {
|
||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("HealthKit authorization failed: \(error)")
|
||||
#endif
|
||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||
}
|
||||
}
|
||||
@@ -1014,7 +1030,7 @@ struct SettingsContentView: View {
|
||||
LocationManager.shared.requestAuthorization()
|
||||
// Check if permission was denied after a brief delay
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
|
||||
let status = LocationManager.shared.authorizationStatus
|
||||
if status == .denied || status == .restricted {
|
||||
weatherEnabled = false
|
||||
@@ -1445,9 +1461,13 @@ struct SettingsView: View {
|
||||
switch result {
|
||||
case .success(let url):
|
||||
AnalyticsManager.shared.track(.dataExported(format: "file", count: 0))
|
||||
#if DEBUG
|
||||
print("Saved to \(url)")
|
||||
#endif
|
||||
case .failure(let error):
|
||||
#if DEBUG
|
||||
print(error.localizedDescription)
|
||||
#endif
|
||||
}
|
||||
})
|
||||
.fileImporter(isPresented: $showingImporter, allowedContentTypes: [.text],
|
||||
@@ -1488,8 +1508,10 @@ struct SettingsView: View {
|
||||
} catch {
|
||||
// Handle failure.
|
||||
AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription))
|
||||
#if DEBUG
|
||||
print("Unable to read file contents")
|
||||
print(error.localizedDescription)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1629,7 +1651,9 @@ struct SettingsView: View {
|
||||
AnalyticsManager.shared.track(.healthKitNotAuthorized)
|
||||
}
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("HealthKit authorization failed: \(error)")
|
||||
#endif
|
||||
AnalyticsManager.shared.track(.healthKitEnableFailed)
|
||||
}
|
||||
}
|
||||
@@ -1719,7 +1743,7 @@ struct SettingsView: View {
|
||||
LocationManager.shared.requestAuthorization()
|
||||
// Check if permission was denied after a brief delay
|
||||
Task {
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
try? await Task.sleep(for: .seconds(SettingsAnimationConstants.locationPermissionCheckDelay))
|
||||
let status = LocationManager.shared.authorizationStatus
|
||||
if status == .denied || status == .restricted {
|
||||
weatherEnabled = false
|
||||
@@ -2280,9 +2304,13 @@ struct SettingsView: View {
|
||||
let url = URL(fileURLWithPath: path)
|
||||
do {
|
||||
try image.jpegData(compressionQuality: 1.0)?.write(to: url, options: .atomic)
|
||||
#if DEBUG
|
||||
print(url)
|
||||
#endif
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print(error.localizedDescription)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,8 @@ struct SwitchableView: View {
|
||||
self.headerTypeChanged(viewType)
|
||||
AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType)))
|
||||
}
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(String(localized: "Switch header view"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user