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
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)"))
}
}

View File

@@ -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!")

View File

@@ -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)
}

View File

@@ -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)")
}
}
}

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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!

View File

@@ -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
}

View File

@@ -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)")
}
}
}
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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)")
}
}
}
}

View File

@@ -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)")
}
}
}
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}
}

View File

@@ -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: {

View File

@@ -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()
}

View File

@@ -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"))) {

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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

View File

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

View File

@@ -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()

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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"))
}
}