diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5e0e036..2c1573f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,10 @@ "WebFetch(domain:azamsharp.com)", "WebFetch(domain:www.createwithswift.com)", "Skill(frontend-design:frontend-design)", - "Bash(npx claude-plugins:*)" + "Bash(npx claude-plugins:*)", + "Bash(cloc:*)", + "Bash(swift -parse:*)", + "Bash(swiftc:*)" ] } } diff --git a/.gitignore b/.gitignore index 312d1f6..8f8f585 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,11 @@ fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output + +# Secrets and API Keys +GoogleService-Info.plist +**/GoogleService-Info.plist +*.xcconfig +!*.xcconfig.template +Secrets.swift +**/Secrets.swift diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 7d2cded..0003416 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -106,12 +106,6 @@ MoodStreakActivity.swift, Onboarding/OnboardingData.swift, Onboarding/views/OnboardingDay.swift, - Persisence/DataController.swift, - Persisence/DataControllerADD.swift, - Persisence/DataControllerDELETE.swift, - Persisence/DataControllerGET.swift, - Persisence/DataControllerHelper.swift, - Persisence/SharedModelContainer.swift, Random.swift, ShowBasedOnVoteLogics.swift, Views/BGView.swift, @@ -356,7 +350,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1320; - LastUpgradeCheck = 2610; + LastUpgradeCheck = 2620; TargetAttributes = { 1CD90AF4278C7DE0001C4FEA = { CreatedOnToolsVersion = 13.2.1; @@ -554,6 +548,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -618,6 +613,7 @@ COPY_PHASE_STRIP = NO; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -648,7 +644,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feels--iOS--Info.plist"; @@ -684,7 +679,6 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 23; - DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Feels--iOS--Info.plist"; @@ -722,7 +716,6 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -754,7 +747,6 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = V3PF3M6B6U; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -780,7 +772,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; @@ -799,7 +790,6 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.2; MARKETING_VERSION = 1.0; @@ -820,7 +810,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; @@ -839,7 +828,6 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = V3PF3M6B6U; GENERATE_INFOPLIST_FILE = YES; MACOSX_DEPLOYMENT_TARGET = 12.1; MARKETING_VERSION = 1.0; @@ -860,8 +848,7 @@ CODE_SIGN_ENTITLEMENTS = FeelsWidgetExtensionDev.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V3PF3M6B6U; + CURRENT_PROJECT_VERSION = 23; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "FeelsWidgetExtension-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = iFeelsWidget; @@ -872,12 +859,13 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.FeelsWidgetDebug; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG WIDGET_EXTENSION"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -892,8 +880,7 @@ CODE_SIGN_ENTITLEMENTS = FeelsWidgetExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = V3PF3M6B6U; + CURRENT_PROJECT_VERSION = 23; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "FeelsWidgetExtension-Info.plist"; INFOPLIST_KEY_CFBundleDisplayName = iFeelsWidget; @@ -904,12 +891,13 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.2; PRODUCT_BUNDLE_IDENTIFIER = com.tt.ifeelDebug.FeelsWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = iphoneos; SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = WIDGET_EXTENSION; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; diff --git a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme index c15c9bb..7ac230c 100644 --- a/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme +++ b/Feels.xcodeproj/xcshareddata/xcschemes/Feels (iOS).xcscheme @@ -1,6 +1,6 @@ Int { - var streak = 0 - var checkDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) - - while true { - let dayStart = Calendar.current.startOfDay(for: checkDate) - let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! - - let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - - if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { - streak += 1 - checkDate = Calendar.current.date(byAdding: .day, value: -1, to: checkDate)! - } else { - break - } - } - - return streak + // Use WidgetDataProvider for read operations + return WidgetDataProvider.shared.getCurrentStreak() } } @@ -142,12 +126,15 @@ struct VoteWidgetProvider: TimelineProvider { private func createEntry() -> VoteWidgetEntry { let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) + // Use WidgetDataProvider for isolated read-only data access + let dataProvider = WidgetDataProvider.shared + let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding()) let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! // Check if user has voted today - let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder // Get today's mood if voted @@ -156,7 +143,7 @@ struct VoteWidgetProvider: TimelineProvider { // Get stats for display after voting var stats: MoodStats? = nil if hasVotedToday { - let allEntries = DataController.shared.getData( + let allEntries = dataProvider.getData( startDate: Date(timeIntervalSince1970: 0), endDate: Date(), includedDays: [] diff --git a/FeelsWidget2/FeelsWidget.swift b/FeelsWidget2/FeelsWidget.swift index 251bbf1..c98495a 100644 --- a/FeelsWidget2/FeelsWidget.swift +++ b/FeelsWidget2/FeelsWidget.swift @@ -157,13 +157,16 @@ struct TimeLineCreator { Calendar.current.date(byAdding: .day, value: -$0, to: latestDayToShow)! }) + // Use WidgetDataProvider for isolated widget data access + let dataProvider = WidgetDataProvider.shared + for date in dates { let dayStart = Calendar.current.startOfDay(for: date) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable() - if let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first { + if let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first { timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: todayEntry.mood), graphic: moodImages.icon(forMood: todayEntry.mood), date: dayStart, @@ -267,7 +270,8 @@ struct Provider: @preconcurrency IntentTimelineProvider { let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart) ?? dayStart - let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + // Use WidgetDataProvider for isolated widget data access + let todayEntry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body diff --git a/FeelsWidget2/WidgetDataProvider.swift b/FeelsWidget2/WidgetDataProvider.swift new file mode 100644 index 0000000..db77f3b --- /dev/null +++ b/FeelsWidget2/WidgetDataProvider.swift @@ -0,0 +1,187 @@ +// +// WidgetDataProvider.swift +// FeelsWidget +// +// Lightweight read-only data provider for widgets. +// Uses its own ModelContainer to avoid conflicts with the main app. +// + +import Foundation +import SwiftData +import os.log + +/// Lightweight read-only data provider for widgets +/// Uses its own ModelContainer to avoid SwiftData conflicts with main app +@MainActor +final class WidgetDataProvider { + + static let shared = WidgetDataProvider() + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "WidgetDataProvider") + + private var _container: ModelContainer? + + private var container: ModelContainer { + if let existing = _container { + return existing + } + let newContainer = createContainer() + _container = newContainer + return newContainer + } + + /// Creates the ModelContainer for widget data access + private func createContainer() -> ModelContainer { + let schema = Schema([MoodEntryModel.self]) + + // Try to use shared app group container + do { + let storeURL = try getStoreURL() + let configuration = ModelConfiguration( + schema: schema, + url: storeURL, + cloudKitDatabase: .none + ) + return try ModelContainer(for: schema, configurations: [configuration]) + } catch { + Self.logger.warning("Falling back to in-memory storage: \(error.localizedDescription)") + // Fall back to in-memory storage + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + do { + return try ModelContainer(for: schema, configurations: [config]) + } catch { + Self.logger.critical("Failed to create ModelContainer: \(error.localizedDescription)") + preconditionFailure("Unable to create ModelContainer: \(error)") + } + } + } + + private func getStoreURL() throws -> URL { + let appGroupID = Constants.currentGroupShareId + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + throw NSError(domain: "WidgetDataProvider", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group not available"]) + } + #if DEBUG + return containerURL.appendingPathComponent("Feels-Debug.store") + #else + return containerURL.appendingPathComponent("Feels.store") + #endif + } + + private var modelContext: ModelContext { + container.mainContext + } + + private init() {} + + // MARK: - Data Access + + /// Get a single entry for a specific date + func getEntry(byDate date: Date) -> MoodEntryModel? { + let startDate = Calendar.current.startOfDay(for: date) + let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + + var descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && entry.forDate <= endDate + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + descriptor.fetchLimit = 1 + + return try? modelContext.fetch(descriptor).first + } + + /// Get entries within a date range, filtered by weekdays + func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] { + let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays + + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && + entry.forDate <= endDate && + weekDays.contains(entry.weekDay) + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + + return (try? modelContext.fetch(descriptor)) ?? [] + } + + /// Get the earliest entry in the database + var earliestEntry: MoodEntryModel? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + descriptor.fetchLimit = 1 + return try? modelContext.fetch(descriptor).first + } + + /// Get the latest entry in the database + var latestEntry: MoodEntryModel? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.forDate, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? modelContext.fetch(descriptor).first + } + + // MARK: - Widget-Specific Helpers + + /// Get today's mood entry + func getTodayEntry() -> MoodEntryModel? { + getEntry(byDate: Date()) + } + + /// Get the current streak count + func getCurrentStreak() -> Int { + let entries = getData( + startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!, + endDate: Date(), + includedDays: [1, 2, 3, 4, 5, 6, 7] + ).sorted { $0.forDate > $1.forDate } + + var streak = 0 + var currentDate = Calendar.current.startOfDay(for: Date()) + + for entry in entries { + let entryDate = Calendar.current.startOfDay(for: entry.forDate) + + if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder { + streak += 1 + currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)! + } else if entryDate < currentDate { + break + } + } + + return streak + } + + /// Invalidate cached container (call when data might have changed) + func invalidateCache() { + _container = nil + } + + // MARK: - Write Operations + + /// Add a new mood entry (simplified version for widget use) + func add(mood: Mood, forDate date: Date, entryType: EntryType) { + // Delete existing entry for this date if present + if let existing = getEntry(byDate: date) { + modelContext.delete(existing) + try? modelContext.save() + } + + let entry = MoodEntryModel( + forDate: date, + mood: mood, + entryType: entryType + ) + + modelContext.insert(entry) + try? modelContext.save() + } +} diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index b0b4751..a61f058 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -8,6 +8,7 @@ import Foundation import StoreKit import SwiftUI +import os.log // MARK: - Subscription State @@ -27,7 +28,11 @@ class IAPManager: ObservableObject { // MARK: - Debug Toggle /// Set to `true` to bypass all subscription checks and grant full access (for development only) + #if DEBUG static let bypassSubscription = true + #else + static let bypassSubscription = false + #endif // MARK: - Constants @@ -151,7 +156,7 @@ class IAPManager: ObservableObject { try await AppStore.sync() await checkSubscriptionStatus() } catch { - print("Failed to restore purchases: \(error)") + AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)") } } @@ -162,7 +167,7 @@ class IAPManager: ObservableObject { let products = try await Product.products(for: productIdentifiers) availableProducts = products.filter { $0.type == .autoRenewable } } catch { - print("Failed to load products: \(error)") + AppLogger.iap.error("Failed to load products: \(error.localizedDescription)") } } diff --git a/Shared/Models/CustomWidgetStateViewModel.swift b/Shared/Models/CustomWidgetStateViewModel.swift new file mode 100644 index 0000000..5f27ad2 --- /dev/null +++ b/Shared/Models/CustomWidgetStateViewModel.swift @@ -0,0 +1,13 @@ +// +// CustomWidgetStateViewModel.swift +// Feels +// +// Created by Trey Tartt on 3/31/22. +// + +import Foundation + +class CustomWidgetStateViewModel: ObservableObject { + @Published var selectedItem: CustomWidgetModel? = nil + @Published var showSheet = false +} diff --git a/Shared/Models/SharingImageModels.swift b/Shared/Models/SharingImageModels.swift index e43a9f3..367038f 100644 --- a/Shared/Models/SharingImageModels.swift +++ b/Shared/Models/SharingImageModels.swift @@ -8,9 +8,9 @@ import SwiftUI import LinkPresentation -class StupidAssShareObservableObject: ObservableObject { - @Published var fuckingWrappedShrable: UIImage? = nil - @Published var showFuckingSheet = false +class ShareImageStateViewModel: ObservableObject { + @Published var selectedShareImage: UIImage? = nil + @Published var showSheet = false } class ShareActivityItemSource: NSObject, UIActivityItemSource { diff --git a/Shared/Models/StupidAssCustomWidgetObservableObject.swift b/Shared/Models/StupidAssCustomWidgetObservableObject.swift deleted file mode 100644 index 4991e28..0000000 --- a/Shared/Models/StupidAssCustomWidgetObservableObject.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// StupidAssCustomWidgetObservableObject.swift -// Feels -// -// Created by Trey Tartt on 3/31/22. -// - -import Foundation - -class StupidAssCustomWidgetObservableObject: ObservableObject { - @Published var fuckingWrapped: CustomWidgetModel? = nil - @Published var showFuckingSheet = false -} diff --git a/Shared/MoodStreakActivity.swift b/Shared/MoodStreakActivity.swift index 5e41485..39e331d 100644 --- a/Shared/MoodStreakActivity.swift +++ b/Shared/MoodStreakActivity.swift @@ -181,7 +181,11 @@ class LiveActivityScheduler: ObservableObject { let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + #if WIDGET_EXTENSION + let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #else let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #endif return entry != nil && entry?.mood != .missing && entry?.mood != .placeholder } @@ -193,7 +197,11 @@ class LiveActivityScheduler: ObservableObject { // Check if current voting date has an entry let currentDayStart = Calendar.current.startOfDay(for: checkDate) let currentDayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: currentDayStart)! + #if WIDGET_EXTENSION + let currentEntry = WidgetDataProvider.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first + #else let currentEntry = DataController.shared.getData(startDate: currentDayStart, endDate: currentDayEnd, includedDays: []).first + #endif // If no entry for current voting date, start counting from previous day // This ensures the streak shows correctly even if user hasn't rated today yet @@ -205,7 +213,11 @@ class LiveActivityScheduler: ObservableObject { let dayStart = Calendar.current.startOfDay(for: checkDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + #if WIDGET_EXTENSION + let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #else let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #endif if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { streak += 1 @@ -224,7 +236,11 @@ class LiveActivityScheduler: ObservableObject { let dayStart = Calendar.current.startOfDay(for: votingDate) let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)! + #if WIDGET_EXTENSION + let entry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #else let entry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + #endif if let entry = entry, entry.mood != .missing && entry.mood != .placeholder { return entry.mood } diff --git a/Shared/Persisence/DataController.swift b/Shared/Persisence/DataController.swift index 59490e9..74adc7f 100644 --- a/Shared/Persisence/DataController.swift +++ b/Shared/Persisence/DataController.swift @@ -7,9 +7,11 @@ import SwiftData import SwiftUI +import os.log @MainActor final class DataController: ObservableObject { + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "DataController") static let shared = DataController() private(set) var container: ModelContainer @@ -45,14 +47,14 @@ final class DataController: ObservableObject { private init() { let cloudKit = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.useCloudKit.rawValue) - container = SharedModelContainer.create(useCloudKit: cloudKit) + container = SharedModelContainer.createWithFallback(useCloudKit: cloudKit) } // MARK: - Container Switching (for CloudKit toggle) func switchContainer() { save() - container = SharedModelContainer.create(useCloudKit: useCloudKit) + container = SharedModelContainer.createWithFallback(useCloudKit: useCloudKit) for listener in switchContainerListeners { listener() } @@ -76,7 +78,7 @@ final class DataController: ObservableObject { do { try modelContext.save() } catch { - print("Failed to save context: \(error)") + Self.logger.error("Failed to save context: \(error.localizedDescription)") } } } diff --git a/Shared/Persisence/DataControllerDELETE.swift b/Shared/Persisence/DataControllerDELETE.swift index ce6a3f6..e223615 100644 --- a/Shared/Persisence/DataControllerDELETE.swift +++ b/Shared/Persisence/DataControllerDELETE.swift @@ -7,6 +7,7 @@ import SwiftData import Foundation +import os.log extension DataController { func clearDB() { @@ -14,7 +15,7 @@ extension DataController { try modelContext.delete(model: MoodEntryModel.self) saveAndRunDataListeners() } catch { - print("Failed to clear database: \(error)") + AppLogger.general.error("Failed to clear database: \(error.localizedDescription)") } } diff --git a/Shared/Persisence/DataControllerProtocol.swift b/Shared/Persisence/DataControllerProtocol.swift new file mode 100644 index 0000000..7dce290 --- /dev/null +++ b/Shared/Persisence/DataControllerProtocol.swift @@ -0,0 +1,244 @@ +// +// DataControllerProtocol.swift +// Feels +// +// Protocol defining the data access interface for mood entries. +// Enables dependency injection and testability. +// + +import Foundation +import SwiftData + +// MARK: - Data Controller Protocol + +/// Protocol defining read-only data access for mood entries +/// Used by widgets and other components that only need to read data +@MainActor +protocol MoodDataReading { + /// Get a single entry for a specific date + func getEntry(byDate date: Date) -> MoodEntryModel? + + /// Get entries within a date range, filtered by weekdays + func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] + + /// Get all data organized by year and month + func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] + + /// The earliest entry in the database + var earliestEntry: MoodEntryModel? { get } + + /// The latest entry in the database + var latestEntry: MoodEntryModel? { get } +} + +/// Protocol defining write operations for mood entries +@MainActor +protocol MoodDataWriting { + /// Add a new mood entry + func add(mood: Mood, forDate date: Date, entryType: EntryType) + + /// Update the mood for an existing entry + @discardableResult + func update(entryDate: Date, withMood mood: Mood) -> Bool + + /// Update notes for an entry + @discardableResult + func updateNotes(forDate date: Date, notes: String?) -> Bool + + /// Update photo for an entry + @discardableResult + func updatePhoto(forDate date: Date, photoID: UUID?) -> Bool + + /// Fill in missing dates with placeholder entries + func fillInMissingDates() + + /// Fix incorrect weekday values + func fixWrongWeekdays() +} + +/// Protocol for data deletion operations +@MainActor +protocol MoodDataDeleting { + /// Clear all entries from the database + func clearDB() + + /// Delete entries from the last N days + func deleteLast(numberOfEntries: Int) +} + +/// Protocol for persistence operations +@MainActor +protocol MoodDataPersisting { + /// Save pending changes + func save() + + /// Save and notify listeners + func saveAndRunDataListeners() + + /// Add a listener for data changes + func addNewDataListener(closure: @escaping (() -> Void)) +} + +/// Combined protocol for full data controller functionality +@MainActor +protocol DataControlling: MoodDataReading, MoodDataWriting, MoodDataDeleting, MoodDataPersisting {} + +// MARK: - DataController Conformance + +extension DataController: DataControlling {} + +// MARK: - Widget Data Provider + +/// Lightweight read-only data provider for widgets +/// Uses UserDefaults-cached data when possible to avoid SwiftData overhead +@MainActor +final class WidgetDataProvider: MoodDataReading { + + static let shared = WidgetDataProvider() + + private var _container: ModelContainer? + + private var container: ModelContainer { + if let existing = _container { + return existing + } + let newContainer = SharedModelContainer.createWithFallback(useCloudKit: false) + _container = newContainer + return newContainer + } + + private var modelContext: ModelContext { + container.mainContext + } + + private init() {} + + // MARK: - MoodDataReading + + func getEntry(byDate date: Date) -> MoodEntryModel? { + let startDate = Calendar.current.startOfDay(for: date) + let endDate = Calendar.current.date(byAdding: .day, value: 1, to: startDate)! + + var descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && entry.forDate <= endDate + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + descriptor.fetchLimit = 1 + + return try? modelContext.fetch(descriptor).first + } + + func getData(startDate: Date, endDate: Date, includedDays: [Int]) -> [MoodEntryModel] { + let weekDays = includedDays.isEmpty ? [1, 2, 3, 4, 5, 6, 7] : includedDays + + let descriptor = FetchDescriptor( + predicate: #Predicate { entry in + entry.forDate >= startDate && + entry.forDate <= endDate && + weekDays.contains(entry.weekDay) + }, + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + + return (try? modelContext.fetch(descriptor)) ?? [] + } + + func splitIntoYearMonth(includedDays: [Int]) -> [Int: [Int: [MoodEntryModel]]] { + let data = getData( + startDate: Date(timeIntervalSince1970: 0), + endDate: Date(), + includedDays: includedDays + ).sorted { $0.forDate < $1.forDate } + + guard let earliest = data.first, + let latest = data.last else { return [:] } + + let calendar = Calendar.current + let earliestYear = calendar.component(.year, from: earliest.forDate) + let latestYear = calendar.component(.year, from: latest.forDate) + + var result = [Int: [Int: [MoodEntryModel]]]() + + for year in earliestYear...latestYear { + var monthData = [Int: [MoodEntryModel]]() + + for month in 1...12 { + var components = DateComponents() + components.year = year + components.month = month + components.day = 1 + + guard let startOfMonth = calendar.date(from: components) else { continue } + + let items = getData( + startDate: startOfMonth, + endDate: startOfMonth.endOfMonth, + includedDays: [1, 2, 3, 4, 5, 6, 7] + ) + + if !items.isEmpty { + monthData[month] = items + } + } + + result[year] = monthData + } + + return result + } + + var earliestEntry: MoodEntryModel? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.forDate, order: .forward)] + ) + descriptor.fetchLimit = 1 + return try? modelContext.fetch(descriptor).first + } + + var latestEntry: MoodEntryModel? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.forDate, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try? modelContext.fetch(descriptor).first + } + + // MARK: - Widget-Specific Helpers + + /// Get today's mood entry + func getTodayEntry() -> MoodEntryModel? { + getEntry(byDate: Date()) + } + + /// Get the current streak count + func getCurrentStreak() -> Int { + let entries = getData( + startDate: Calendar.current.date(byAdding: .day, value: -365, to: Date())!, + endDate: Date(), + includedDays: [1, 2, 3, 4, 5, 6, 7] + ).sorted { $0.forDate > $1.forDate } + + var streak = 0 + var currentDate = Calendar.current.startOfDay(for: Date()) + + for entry in entries { + let entryDate = Calendar.current.startOfDay(for: entry.forDate) + + if entryDate == currentDate && entry.mood != .missing && entry.mood != .placeholder { + streak += 1 + currentDate = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)! + } else if entryDate < currentDate { + break + } + } + + return streak + } + + /// Invalidate cached container (call when data might have changed) + func invalidateCache() { + _container = nil + } +} diff --git a/Shared/Persisence/SharedModelContainer.swift b/Shared/Persisence/SharedModelContainer.swift index ddc1d80..c251b8e 100644 --- a/Shared/Persisence/SharedModelContainer.swift +++ b/Shared/Persisence/SharedModelContainer.swift @@ -7,14 +7,33 @@ import Foundation import SwiftData +import os.log + +/// Errors that can occur when creating the shared model container +enum SharedModelContainerError: LocalizedError { + case appGroupNotAvailable(String) + case modelContainerCreationFailed(Error) + + var errorDescription: String? { + switch self { + case .appGroupNotAvailable(let groupID): + return "App Group container not available for: \(groupID)" + case .modelContainerCreationFailed(let error): + return "Failed to create ModelContainer: \(error.localizedDescription)" + } + } +} enum SharedModelContainer { + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.tt.ifeel", category: "SharedModelContainer") + /// Creates a ModelContainer with the appropriate configuration for app group sharing /// - Parameter useCloudKit: Whether to enable CloudKit sync /// - Returns: Configured ModelContainer - static func create(useCloudKit: Bool = false) -> ModelContainer { + /// - Throws: SharedModelContainerError if creation fails + static func create(useCloudKit: Bool = false) throws -> ModelContainer { let schema = Schema([MoodEntryModel.self]) - let storeURL = Self.storeURL + let storeURL = try Self.storeURL let configuration: ModelConfiguration if useCloudKit { @@ -36,18 +55,44 @@ enum SharedModelContainer { do { return try ModelContainer(for: schema, configurations: [configuration]) } catch { - fatalError("Failed to create ModelContainer: \(error)") + logger.error("Failed to create ModelContainer: \(error.localizedDescription)") + throw SharedModelContainerError.modelContainerCreationFailed(error) + } + } + + /// Creates a ModelContainer, falling back to in-memory storage if shared container fails + /// - Parameter useCloudKit: Whether to enable CloudKit sync + /// - Returns: Configured ModelContainer (shared or in-memory fallback) + static func createWithFallback(useCloudKit: Bool = false) -> ModelContainer { + do { + return try create(useCloudKit: useCloudKit) + } catch { + logger.warning("Falling back to in-memory storage due to: \(error.localizedDescription)") + // Fall back to in-memory storage + let schema = Schema([MoodEntryModel.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + do { + return try ModelContainer(for: schema, configurations: [config]) + } catch { + // This should never happen with in-memory storage, but handle it gracefully + logger.critical("Failed to create even in-memory ModelContainer: \(error.localizedDescription)") + preconditionFailure("Unable to create ModelContainer: \(error)") + } } } /// The URL for the SwiftData store in the shared app group container + /// - Throws: SharedModelContainerError if app group is not available static var storeURL: URL { - guard let containerURL = FileManager.default.containerURL( - forSecurityApplicationGroupIdentifier: appGroupID - ) else { - fatalError("App Group container not available for: \(appGroupID)") + get throws { + guard let containerURL = FileManager.default.containerURL( + forSecurityApplicationGroupIdentifier: appGroupID + ) else { + logger.error("App Group container not available for: \(appGroupID)") + throw SharedModelContainerError.appGroupNotAvailable(appGroupID) + } + return containerURL.appendingPathComponent(storeFileName) } - return containerURL.appendingPathComponent(storeFileName) } /// App Group identifier based on build configuration diff --git a/Shared/Services/AppLogger.swift b/Shared/Services/AppLogger.swift new file mode 100644 index 0000000..0054a8e --- /dev/null +++ b/Shared/Services/AppLogger.swift @@ -0,0 +1,34 @@ +// +// AppLogger.swift +// Feels +// +// Centralized logging using OSLog for production-ready logging. +// + +import Foundation +import os.log + +/// Centralized logging utility using OSLog +enum AppLogger { + // MARK: - Loggers by Category + + static let general = Logger(subsystem: subsystem, category: "General") + static let iap = Logger(subsystem: subsystem, category: "IAP") + static let healthKit = Logger(subsystem: subsystem, category: "HealthKit") + static let liveActivity = Logger(subsystem: subsystem, category: "LiveActivity") + static let notifications = Logger(subsystem: subsystem, category: "Notifications") + static let photos = Logger(subsystem: subsystem, category: "Photos") + static let export = Logger(subsystem: subsystem, category: "Export") + static let settings = Logger(subsystem: subsystem, category: "Settings") + static let biometrics = Logger(subsystem: subsystem, category: "Biometrics") + static let ai = Logger(subsystem: subsystem, category: "AI") + static let events = Logger(subsystem: subsystem, category: "Events") + static let userDefaults = Logger(subsystem: subsystem, category: "UserDefaults") + static let backgroundTasks = Logger(subsystem: subsystem, category: "BackgroundTasks") + + // MARK: - Private + + private static var subsystem: String { + Bundle.main.bundleIdentifier ?? "com.tt.ifeel" + } +} diff --git a/Shared/Services/BiometricAuthManager.swift b/Shared/Services/BiometricAuthManager.swift index 91804ab..2fa22d2 100644 --- a/Shared/Services/BiometricAuthManager.swift +++ b/Shared/Services/BiometricAuthManager.swift @@ -45,6 +45,8 @@ class BiometricAuthManager: ObservableObject { var biometricName: String { switch biometricType { + case .none: + return "Passcode" case .faceID: return "Face ID" case .touchID: @@ -58,6 +60,8 @@ class BiometricAuthManager: ObservableObject { var biometricIcon: String { switch biometricType { + case .none: + return "lock.fill" case .faceID: return "faceid" case .touchID: diff --git a/Shared/Services/ExportService.swift b/Shared/Services/ExportService.swift index ebe530b..684e446 100644 --- a/Shared/Services/ExportService.swift +++ b/Shared/Services/ExportService.swift @@ -277,7 +277,7 @@ class ExportService { // MARK: - PDF Drawing: Mood Distribution Chart private func drawMoodDistributionChart(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { - var currentY = drawSectionTitle("Mood Distribution", at: y, margin: margin) + let currentY = drawSectionTitle("Mood Distribution", at: y, margin: margin) let chartHeight: CGFloat = 140 let barHeight: CGFloat = 22 @@ -334,7 +334,7 @@ class ExportService { // MARK: - PDF Drawing: Weekday Analysis private func drawWeekdayAnalysis(at y: CGFloat, entries: [MoodEntryModel], margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { - var currentY = drawSectionTitle("Mood by Day of Week", at: y, margin: margin) + let currentY = drawSectionTitle("Mood by Day of Week", at: y, margin: margin) let weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] var weekdayAverages: [Double] = Array(repeating: 0, count: 7) @@ -488,7 +488,7 @@ class ExportService { // MARK: - PDF Drawing: Streaks Section private func drawStreaksSection(at y: CGFloat, stats: ExportStats, margin: CGFloat, width: CGFloat, in context: UIGraphicsPDFRendererContext) -> CGFloat { - var currentY = drawSectionTitle("Streaks & Consistency", at: y, margin: margin) + let currentY = drawSectionTitle("Streaks & Consistency", at: y, margin: margin) let cardWidth = (width - 15) / 2 let cardHeight: CGFloat = 60 diff --git a/Shared/Services/ImageCache.swift b/Shared/Services/ImageCache.swift new file mode 100644 index 0000000..570fecc --- /dev/null +++ b/Shared/Services/ImageCache.swift @@ -0,0 +1,173 @@ +// +// ImageCache.swift +// Feels +// +// In-memory image cache for thumbnail images to improve scrolling performance. +// + +import UIKit +import SwiftUI + +/// Thread-safe in-memory image cache +final class ImageCache { + static let shared = ImageCache() + + private let cache = NSCache() + private let queue = DispatchQueue(label: "com.tt.ifeel.imagecache", qos: .userInitiated) + + private init() { + // Configure cache limits + cache.countLimit = 100 // Max 100 images + cache.totalCostLimit = 50 * 1024 * 1024 // 50 MB max + + // Clear cache on memory warning + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.clearCache() + } + } + + // MARK: - Public API + + /// Get image from cache + func image(forKey key: String) -> UIImage? { + queue.sync { + cache.object(forKey: key as NSString) + } + } + + /// Store image in cache + func setImage(_ image: UIImage, forKey key: String) { + queue.async { [weak self] in + let cost = Int(image.size.width * image.size.height * 4) // Approximate memory cost + self?.cache.setObject(image, forKey: key as NSString, cost: cost) + } + } + + /// Remove image from cache + func removeImage(forKey key: String) { + queue.async { [weak self] in + self?.cache.removeObject(forKey: key as NSString) + } + } + + /// Clear all cached images + func clearCache() { + queue.async { [weak self] in + self?.cache.removeAllObjects() + } + } + + // MARK: - Convenience Methods for Photo IDs + + /// Get cached image for photo ID + func image(forPhotoID id: UUID) -> UIImage? { + image(forKey: id.uuidString) + } + + /// Store image for photo ID + func setImage(_ image: UIImage, forPhotoID id: UUID) { + setImage(image, forKey: id.uuidString) + } + + /// Remove cached image for photo ID + func removeImage(forPhotoID id: UUID) { + removeImage(forKey: id.uuidString) + } +} + +// MARK: - Cached Photo Loading + +extension PhotoManager { + /// Load thumbnail with caching + func loadCachedThumbnail(id: UUID) -> UIImage? { + // Check cache first + if let cached = ImageCache.shared.image(forPhotoID: id) { + return cached + } + + // Load from disk + guard let image = loadThumbnail(id: id) else { + return nil + } + + // Cache for future use + ImageCache.shared.setImage(image, forPhotoID: id) + return image + } + + /// Load full image with caching + func loadCachedPhoto(id: UUID) -> UIImage? { + let cacheKey = "\(id.uuidString)-full" + + // Check cache first + if let cached = ImageCache.shared.image(forKey: cacheKey) { + return cached + } + + // Load from disk + guard let image = loadPhoto(id: id) else { + return nil + } + + // Cache for future use + ImageCache.shared.setImage(image, forKey: cacheKey) + return image + } +} + +// MARK: - SwiftUI Cached Image View + +struct CachedAsyncImage: View { + let photoID: UUID? + let useThumbnail: Bool + + @State private var image: UIImage? + @State private var isLoading = false + + init(photoID: UUID?, useThumbnail: Bool = true) { + self.photoID = photoID + self.useThumbnail = useThumbnail + } + + var body: some View { + Group { + if let image = image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } else if isLoading { + ProgressView() + } else { + Color.gray.opacity(0.2) + } + } + .onAppear { + loadImage() + } + } + + @MainActor + private func loadImage() { + guard let id = photoID else { return } + isLoading = true + + Task { + let loadedImage = await Task.detached(priority: .userInitiated) { + if useThumbnail { + return await PhotoManager.shared.loadCachedThumbnail(id: id) + } else { + return await PhotoManager.shared.loadCachedPhoto(id: id) + } + }.value + + await MainActor.run { + self.image = loadedImage + self.isLoading = false + } + } + } +} diff --git a/Shared/Services/PhotoManager.swift b/Shared/Services/PhotoManager.swift index e9e8c3d..759add3 100644 --- a/Shared/Services/PhotoManager.swift +++ b/Shared/Services/PhotoManager.swift @@ -9,6 +9,7 @@ import Foundation import UIKit import SwiftUI +import os.log @MainActor class PhotoManager: ObservableObject { @@ -26,7 +27,7 @@ class PhotoManager: ObservableObject { guard let containerURL = FileManager.default.containerURL( forSecurityApplicationGroupIdentifier: Constants.currentGroupShareId ) else { - print("PhotoManager: Failed to get app group container") + AppLogger.photos.error("Failed to get app group container") return nil } @@ -37,7 +38,7 @@ class PhotoManager: ObservableObject { do { try FileManager.default.createDirectory(at: photosURL, withIntermediateDirectories: true) } catch { - print("PhotoManager: Failed to create photos directory: \(error)") + AppLogger.photos.error("Failed to create photos directory: \(error.localizedDescription)") return nil } } @@ -54,7 +55,7 @@ class PhotoManager: ObservableObject { do { try FileManager.default.createDirectory(at: thumbnailsURL, withIntermediateDirectories: true) } catch { - print("PhotoManager: Failed to create thumbnails directory: \(error)") + AppLogger.photos.error("Failed to create thumbnails directory: \(error.localizedDescription)") return nil } } @@ -76,14 +77,14 @@ class PhotoManager: ObservableObject { // Save full resolution let fullURL = photosDir.appendingPathComponent(filename) guard let fullData = image.jpegData(compressionQuality: compressionQuality) else { - print("PhotoManager: Failed to create JPEG data") + AppLogger.photos.error("Failed to create JPEG data") return nil } do { try fullData.write(to: fullURL) } catch { - print("PhotoManager: Failed to save photo: \(error)") + AppLogger.photos.error("Failed to save photo: \(error.localizedDescription)") return nil } @@ -151,7 +152,7 @@ class PhotoManager: ObservableObject { do { try FileManager.default.removeItem(at: fullURL) } catch { - print("PhotoManager: Failed to delete photo: \(error)") + AppLogger.photos.error("Failed to delete photo: \(error.localizedDescription)") success = false } } diff --git a/Shared/ShowBasedOnVoteLogics.swift b/Shared/ShowBasedOnVoteLogics.swift index 2e5fc16..5fbbced 100644 --- a/Shared/ShowBasedOnVoteLogics.swift +++ b/Shared/ShowBasedOnVoteLogics.swift @@ -61,7 +61,11 @@ class ShowBasedOnVoteLogics { static public func isMissingCurrentVote(onboardingData: OnboardingData) -> Bool { let startDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData).startOfDay + #if WIDGET_EXTENSION + let entry = WidgetDataProvider.shared.getEntry(byDate: startDate) + #else let entry = DataController.shared.getEntry(byDate: startDate) + #endif return entry == nil || entry?.mood == .missing } diff --git a/Shared/Utilities/AccessibilityHelpers.swift b/Shared/Utilities/AccessibilityHelpers.swift new file mode 100644 index 0000000..72b2367 --- /dev/null +++ b/Shared/Utilities/AccessibilityHelpers.swift @@ -0,0 +1,119 @@ +// +// AccessibilityHelpers.swift +// Feels +// +// Accessibility utilities for supporting VoiceOver, Dynamic Type, and Reduce Motion. +// + +import SwiftUI + +// MARK: - Reduce Motion Support + +/// Environment key for accessing reduce motion preference +struct ReduceMotionKey: EnvironmentKey { + static let defaultValue: Bool = false +} + +extension EnvironmentValues { + var reduceMotion: Bool { + get { self[ReduceMotionKey.self] } + set { self[ReduceMotionKey.self] = newValue } + } +} + +/// View modifier that respects reduce motion preference +struct ReduceMotionModifier: ViewModifier { + @Environment(\.accessibilityReduceMotion) var reduceMotion + + let animation: Animation? + let reducedAnimation: Animation? + + func body(content: Content) -> some View { + content + .animation(reduceMotion ? reducedAnimation : animation, value: UUID()) + } +} + +extension View { + /// Applies animation only when reduce motion is disabled + func accessibleAnimation(_ animation: Animation? = .default, reduced: Animation? = nil) -> some View { + modifier(ReduceMotionModifier(animation: animation, reducedAnimation: reduced)) + } + + /// Wraps content in withAnimation respecting reduce motion + func withAccessibleAnimation(_ animation: Animation? = .default, value: V, action: @escaping () -> Void) -> some View { + self.onChange(of: value) { _, _ in + if UIAccessibility.isReduceMotionEnabled { + action() + } else { + withAnimation(animation) { + action() + } + } + } + } +} + +// MARK: - Accessibility Helpers + +extension View { + /// Adds accessibility label with optional hint + func accessibleMoodCell(mood: Mood, date: Date) -> some View { + let formatter = DateFormatter() + formatter.dateStyle = .medium + + return self + .accessibilityLabel("\(mood.strValue) on \(formatter.string(from: date))") + .accessibilityHint("Double tap to edit mood") + } + + /// Makes a button accessible with custom label + func accessibleButton(label: String, hint: String? = nil) -> some View { + self + .accessibilityLabel(label) + .accessibilityHint(hint ?? "") + .accessibilityAddTraits(.isButton) + } + + /// Groups related elements for VoiceOver + func accessibilityGrouped(label: String) -> some View { + self + .accessibilityElement(children: .combine) + .accessibilityLabel(label) + } +} + +// MARK: - Dynamic Type Support + +extension Font { + /// Returns a scalable font that respects Dynamic Type + static func scalable(_ style: Font.TextStyle, weight: Font.Weight = .regular) -> Font { + Font.system(style, design: .rounded).weight(weight) + } +} + +extension View { + /// Ensures minimum touch target size for accessibility (44x44 points) + func accessibleTouchTarget() -> some View { + self.frame(minWidth: 44, minHeight: 44) + } +} + +// MARK: - Accessibility Announcements + +struct AccessibilityAnnouncement { + /// Announces a message to VoiceOver users + static func announce(_ message: String) { + UIAccessibility.post(notification: .announcement, argument: message) + } + + /// Notifies VoiceOver that screen content has changed + static func screenChanged() { + UIAccessibility.post(notification: .screenChanged, argument: nil) + } + + /// Notifies VoiceOver that layout has changed + static func layoutChanged(focusElement: Any? = nil) { + UIAccessibility.post(notification: .layoutChanged, argument: focusElement) + } +} diff --git a/Shared/Views/BGView.swift b/Shared/Views/BGView.swift index d0108c1..3a7c05d 100644 --- a/Shared/Views/BGView.swift +++ b/Shared/Views/BGView.swift @@ -42,12 +42,9 @@ struct BGView: View, Equatable { var numDown: Int let iconSize = 35 - init() { - let screenWidth = UIScreen.main.bounds.width - numAcross = Int(screenWidth)/iconSize - - let screenHeight = UIScreen.main.bounds.height - numDown = Int(screenHeight)/iconSize + init(screenSize: CGSize = CGSize(width: 393, height: 852)) { + numAcross = Int(screenSize.width) / iconSize + numDown = Int(screenSize.height) / iconSize } var randomMood: Mood? { @@ -80,6 +77,7 @@ struct BGView: View, Equatable { } } +#if !WIDGET_EXTENSION struct BGView_Previews: PreviewProvider { static var previews: some View { BGView().modelContainer(DataController.shared.container) @@ -92,3 +90,4 @@ struct BGView_Previews: PreviewProvider { .modelContainer(DataController.shared.container) } } +#endif diff --git a/Shared/Views/CustomIcon/IconViewModel.swift b/Shared/Views/CustomIcon/IconViewModel.swift index 2fca0ad..db5feb6 100644 --- a/Shared/Views/CustomIcon/IconViewModel.swift +++ b/Shared/Views/CustomIcon/IconViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI +@MainActor class IconViewModel: ObservableObject { static let numberOfBGItems = 109 diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index f7c52d9..6f1e3b7 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -579,7 +579,7 @@ struct VotingLayoutPickerCompact: View { struct CustomWidgetSection: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - @StateObject private var selectedWidget = StupidAssCustomWidgetObservableObject() + @StateObject private var selectedWidget = CustomWidgetStateViewModel() var body: some View { VStack(spacing: 12) { @@ -591,16 +591,16 @@ struct CustomWidgetSection: View { .cornerRadius(12) .onTapGesture { EventLogger.log(event: "show_widget") - selectedWidget.fuckingWrapped = widget.copy() as? CustomWidgetModel - selectedWidget.showFuckingSheet = true + selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel + selectedWidget.showSheet = true } } // Add button Button(action: { EventLogger.log(event: "tap_create_new_widget") - selectedWidget.fuckingWrapped = CustomWidgetModel.randomWidget - selectedWidget.showFuckingSheet = true + selectedWidget.selectedItem = CustomWidgetModel.randomWidget + selectedWidget.showSheet = true }) { ZStack { RoundedRectangle(cornerRadius: 12) @@ -625,9 +625,9 @@ struct CustomWidgetSection: View { .foregroundColor(.accentColor) } } - .sheet(isPresented: $selectedWidget.showFuckingSheet) { - if let fuckingWrapped = selectedWidget.fuckingWrapped { - CreateWidgetView(customWidget: fuckingWrapped) + .sheet(isPresented: $selectedWidget.showSheet) { + if let selectedItem = selectedWidget.selectedItem { + CreateWidgetView(customWidget: selectedItem) } } } diff --git a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift index d312fa1..0e17b2b 100644 --- a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift +++ b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift @@ -10,7 +10,7 @@ import SwiftUI struct CustomWigetView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - @StateObject private var selectedWidget = StupidAssCustomWidgetObservableObject() + @StateObject private var selectedWidget = CustomWidgetStateViewModel() var body: some View { ZStack { @@ -24,8 +24,8 @@ struct CustomWigetView: View { .cornerRadius(10) .onTapGesture { EventLogger.log(event: "show_widget") - selectedWidget.fuckingWrapped = widget.copy() as? CustomWidgetModel - selectedWidget.showFuckingSheet = true + selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel + selectedWidget.showSheet = true } } RoundedRectangle(cornerRadius: 10).fill().foregroundColor(theme.currentTheme.secondaryBGColor) @@ -35,8 +35,8 @@ struct CustomWigetView: View { ) .onTapGesture { EventLogger.log(event: "tap_create_new_widget") - selectedWidget.fuckingWrapped = CustomWidgetModel.randomWidget - selectedWidget.showFuckingSheet = true + selectedWidget.selectedItem = CustomWidgetModel.randomWidget + selectedWidget.showSheet = true } } .padding() @@ -52,9 +52,9 @@ struct CustomWigetView: View { } .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) - .sheet(isPresented: $selectedWidget.showFuckingSheet) { - if let fuckingWrapped = selectedWidget.fuckingWrapped { - CreateWidgetView(customWidget: fuckingWrapped) + .sheet(isPresented: $selectedWidget.showSheet) { + if let selectedItem = selectedWidget.selectedItem { + CreateWidgetView(customWidget: selectedItem) } } } diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift index afc14b8..815d586 100644 --- a/Shared/Views/DayView/DayViewViewModel.swift +++ b/Shared/Views/DayView/DayViewViewModel.swift @@ -35,12 +35,14 @@ class DayViewViewModel: ObservableObject { init(addMonthStartWeekdayPadding: Bool) { self.addMonthStartWeekdayPadding = addMonthStartWeekdayPadding - DataController.shared.switchContainerListeners.append { + DataController.shared.switchContainerListeners.append { [weak self] in + guard let self = self else { return } self.getGroupedData(addMonthStartWeekdayPadding: self.addMonthStartWeekdayPadding) } - DataController.shared.addNewDataListener { - withAnimation{ + DataController.shared.addNewDataListener { [weak self] in + guard let self = self else { return } + withAnimation { self.updateData() } } diff --git a/Shared/Views/MonthView/MonthDetailView.swift b/Shared/Views/MonthView/MonthDetailView.swift index d410919..1ca5d35 100644 --- a/Shared/Views/MonthView/MonthDetailView.swift +++ b/Shared/Views/MonthView/MonthDetailView.swift @@ -13,7 +13,7 @@ struct MonthDetailView: View { @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() @State private var showingSheet = false @State private var selectedEntry: MoodEntryModel? @@ -58,8 +58,8 @@ struct MonthDetailView: View { impactMed.impactOccurred() let _image = self.image - self.shareImage.showFuckingSheet = true - self.shareImage.fuckingWrappedShrable = _image + self.shareImage.showSheet = true + self.shareImage.selectedShareImage = _image } } .background( @@ -81,8 +81,8 @@ struct MonthDetailView: View { .background( theme.currentTheme.bg ) - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ShareSheet(photo: uiImage) } } diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index bc9d8e5..b92a448 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -18,20 +18,20 @@ struct MonthView: View { @AppStorage(UserDefaultsStore.Keys.shape.rawValue, store: GroupUserDefaults.groupDefaults) private var shape: BGShape = .circle - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() // store a value that gets changed when user updates custom colors to update the view since the moodTint doesn't change @AppStorage(UserDefaultsStore.Keys.customMoodTintUpdateNumber.rawValue, store: GroupUserDefaults.groupDefaults) private var customMoodTintUpdateNumber: Int = 0 @EnvironmentObject var iapManager: IAPManager - @StateObject private var selectedDetail = StupidAssDetailViewObservableObject() + @StateObject private var selectedDetail = DetailViewStateViewModel() @State private var showingSheet = false @StateObject private var onboardingData = OnboardingDataDataManager.shared @StateObject private var filteredDays = DaysFilterClass.shared - class StupidAssDetailViewObservableObject: ObservableObject { - @Published var fuckingWrapped: MonthDetailView? = nil - @Published var showFuckingSheet = false + class DetailViewStateViewModel: ObservableObject { + @Published var selectedItem: MonthDetailView? = nil + @Published var showSheet = false } // Heatmap-style grid with tight spacing @@ -70,12 +70,12 @@ struct MonthView: View { entries: entries, parentViewModel: viewModel ) - selectedDetail.fuckingWrapped = detailView - selectedDetail.showFuckingSheet = true + selectedDetail.selectedItem = detailView + selectedDetail.showSheet = true }, onShare: { image in - shareImage.fuckingWrappedShrable = image - shareImage.showFuckingSheet = true + shareImage.selectedShareImage = image + shareImage.showSheet = true } ) } @@ -139,12 +139,12 @@ struct MonthView: View { theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) - .sheet(isPresented: $selectedDetail.showFuckingSheet, + .sheet(isPresented: $selectedDetail.showSheet, onDismiss: didDismiss) { - selectedDetail.fuckingWrapped + selectedDetail.selectedItem } - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ImageOnlyShareSheet(photo: uiImage) } } @@ -157,8 +157,8 @@ struct MonthView: View { func didDismiss() { - selectedDetail.showFuckingSheet = false - selectedDetail.fuckingWrapped = nil + selectedDetail.showSheet = false + selectedDetail.selectedItem = nil } } @@ -329,8 +329,8 @@ struct MonthCard: View { // Weekday Labels HStack(spacing: 2) { - ForEach(weekdayLabels, id: \.self) { day in - Text(day) + ForEach(weekdayLabels.indices, id: \.self) { index in + Text(weekdayLabels[index]) .font(.caption2.weight(.medium)) .foregroundColor(textColor.opacity(0.5)) .frame(maxWidth: .infinity) @@ -384,6 +384,26 @@ struct HeatmapCell: View { RoundedRectangle(cornerRadius: 4) .fill(cellColor) .aspectRatio(1, contentMode: .fit) + .accessibilityLabel(accessibilityDescription) + .accessibilityHint(entry.mood != .placeholder && entry.mood != .missing ? "Double tap to edit" : "") + } + + private var accessibilityDescription: String { + if entry.mood == .placeholder { + return "Empty day" + } else if entry.mood == .missing { + return "No mood logged for \(formattedDate)" + } else if !isFiltered { + return "\(formattedDate): \(entry.mood.strValue) (filtered out)" + } else { + return "\(formattedDate): \(entry.mood.strValue)" + } + } + + private var formattedDate: String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + return formatter.string(from: entry.forDate) } private var cellColor: Color { diff --git a/Shared/Views/NoteEditorView.swift b/Shared/Views/NoteEditorView.swift index 9c8de13..9c7e74f 100644 --- a/Shared/Views/NoteEditorView.swift +++ b/Shared/Views/NoteEditorView.swift @@ -461,7 +461,7 @@ struct EntryDetailView: View { } if entry.photoID != nil { Button("Remove Photo", role: .destructive) { - PhotoManager.shared.deletePhoto(id: entry.photoID!) + _ = PhotoManager.shared.deletePhoto(id: entry.photoID!) _ = DataController.shared.updatePhoto(forDate: entry.forDate, photoID: nil) } } diff --git a/Shared/Views/PurchaseButtonView.swift b/Shared/Views/PurchaseButtonView.swift index e093b91..84eba00 100644 --- a/Shared/Views/PurchaseButtonView.swift +++ b/Shared/Views/PurchaseButtonView.swift @@ -181,14 +181,7 @@ struct PurchaseButtonView: View { .foregroundColor(.orange) if let expirationDate = iapManager.trialExpirationDate { - Text(String(localized: "purchase_view_trial_expires_in")) - .foregroundColor(textColor) - + - Text(" ") - + - Text(expirationDate, style: .relative) - .foregroundColor(.orange) - .bold() + Text("\(Text(String(localized: "purchase_view_trial_expires_in")).foregroundColor(textColor)) \(Text(expirationDate, style: .relative).foregroundColor(.orange).bold())") } } .font(.body) diff --git a/Shared/Views/SettingsView/SettingsTabView.swift b/Shared/Views/SettingsView/SettingsTabView.swift index 6b7fba2..48b81ef 100644 --- a/Shared/Views/SettingsView/SettingsTabView.swift +++ b/Shared/Views/SettingsView/SettingsTabView.swift @@ -96,13 +96,7 @@ struct UpgradeBannerView: View { .foregroundColor(.orange) if let expirationDate = trialExpirationDate { - Text("Trial expires in ") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(textColor.opacity(0.8)) - + - Text(expirationDate, style: .relative) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.orange) + Text("\(Text("Trial expires in ").font(.system(size: 14, weight: .medium)).foregroundColor(textColor.opacity(0.8)))\(Text(expirationDate, style: .relative).font(.system(size: 14, weight: .bold)).foregroundColor(.orange))") } else { Text("Trial expired") .font(.system(size: 14, weight: .medium)) diff --git a/Shared/Views/Sharing/SharingListView.swift b/Shared/Views/Sharing/SharingListView.swift index c3c0eda..3df77cb 100644 --- a/Shared/Views/Sharing/SharingListView.swift +++ b/Shared/Views/Sharing/SharingListView.swift @@ -26,12 +26,12 @@ struct SharingListView: View { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = DefaultTextColor.textColor - class StupidAssObservableObject: ObservableObject { - @Published var fuckingWrappedShrable: WrappedSharable? = nil - @Published var showFuckingSheet = false + class ShareStateViewModel: ObservableObject { + @Published var selectedItem: WrappedSharable? = nil + @Published var showSheet = false } - @StateObject private var selectedShare = StupidAssObservableObject() + @StateObject private var selectedShare = ShareStateViewModel() var sharebleItems = [WrappedSharable]() @MainActor @@ -91,8 +91,8 @@ struct SharingListView: View { func didDismiss() { - selectedShare.showFuckingSheet = false - selectedShare.fuckingWrappedShrable = nil + selectedShare.showSheet = false + selectedShare.selectedItem = nil } var body: some View { @@ -100,8 +100,8 @@ struct SharingListView: View { ScrollView { ForEach(sharebleItems, id: \.self) { item in Button(action: { - selectedShare.fuckingWrappedShrable = item - selectedShare.showFuckingSheet = true + selectedShare.selectedItem = item + selectedShare.showSheet = true }, label: { ZStack { theme.currentTheme.secondaryBGColor @@ -127,7 +127,7 @@ struct SharingListView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .scaledToFill() .clipped() - .contentShape(Path(CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 88))) + .contentShape(Rectangle()) .padding([.leading, .trailing]) }) } @@ -139,9 +139,9 @@ struct SharingListView: View { theme.currentTheme.bg .edgesIgnoringSafeArea(.top) ) - .sheet(isPresented: $selectedShare.showFuckingSheet, + .sheet(isPresented: $selectedShare.showSheet, onDismiss: didDismiss) { - selectedShare.fuckingWrappedShrable?.destination + selectedShare.selectedItem?.destination } } diff --git a/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift b/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift index 000ac9d..60ab62f 100644 --- a/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift +++ b/Shared/Views/SharingTemplates/AllMoodsTotalTemplate.swift @@ -22,7 +22,7 @@ struct AllMoodsTotalTemplate: View, SharingTemplate { @AppStorage(UserDefaultsStore.Keys.theme.rawValue, store: GroupUserDefaults.groupDefaults) private var theme: Theme = .system @AppStorage(UserDefaultsStore.Keys.textColor.rawValue, store: GroupUserDefaults.groupDefaults) private var textColor: Color = .white - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() private var entries = [MoodMetrics]() @MainActor @@ -166,8 +166,8 @@ struct AllMoodsTotalTemplate: View, SharingTemplate { HStack(alignment: .center) { Button(action: { let _image = self.image - self.shareImage.showFuckingSheet = true - self.shareImage.fuckingWrappedShrable = _image + self.shareImage.showSheet = true + self.shareImage.selectedShareImage = _image }, label: { Text("Share") .font(.title) @@ -206,8 +206,8 @@ struct AllMoodsTotalTemplate: View, SharingTemplate { .scaleEffect(2) } else { mainView - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ShareSheet(photo: uiImage) } } diff --git a/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift b/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift index 73e7ea6..32c10bc 100644 --- a/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift +++ b/Shared/Views/SharingTemplates/CurrentStreakTemplate.swift @@ -18,7 +18,7 @@ struct CurrentStreakTemplate: View, SharingTemplate { let moodEntries: [MoodEntryModel] @State var showSharingTemplate = false - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @@ -109,8 +109,8 @@ struct CurrentStreakTemplate: View, SharingTemplate { HStack(alignment: .center) { Button(action: { let _image = self.image - self.shareImage.showFuckingSheet = true - self.shareImage.fuckingWrappedShrable = _image + self.shareImage.showSheet = true + self.shareImage.selectedShareImage = _image }, label: { Text("Share") .font(.title) @@ -118,8 +118,8 @@ struct CurrentStreakTemplate: View, SharingTemplate { .foregroundColor(Color.white) .padding(.top, 20) }) - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ShareSheet(photo: uiImage) } } diff --git a/Shared/Views/SharingTemplates/LongestStreakTemplate.swift b/Shared/Views/SharingTemplates/LongestStreakTemplate.swift index f427fc6..6af6fb7 100644 --- a/Shared/Views/SharingTemplates/LongestStreakTemplate.swift +++ b/Shared/Views/SharingTemplates/LongestStreakTemplate.swift @@ -28,7 +28,7 @@ struct LongestStreakTemplate: View, SharingTemplate { @State var selectedMood: Mood = .great @State var showSharingTemplate = false - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @@ -185,8 +185,8 @@ struct LongestStreakTemplate: View, SharingTemplate { HStack(alignment: .center) { Button(action: { let _image = self.image - self.shareImage.showFuckingSheet = true - self.shareImage.fuckingWrappedShrable = _image + self.shareImage.showSheet = true + self.shareImage.selectedShareImage = _image }, label: { Text("Share") .font(.title) @@ -194,8 +194,8 @@ struct LongestStreakTemplate: View, SharingTemplate { .foregroundColor(Color.white) .padding(.top, 20) }) - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ShareSheet(photo: uiImage) } } diff --git a/Shared/Views/SharingTemplates/MonthTotalTemplate.swift b/Shared/Views/SharingTemplates/MonthTotalTemplate.swift index 3afd9e8..52da7d5 100644 --- a/Shared/Views/SharingTemplates/MonthTotalTemplate.swift +++ b/Shared/Views/SharingTemplates/MonthTotalTemplate.swift @@ -20,7 +20,7 @@ struct MonthTotalTemplate: View, SharingTemplate { private var month = Calendar.current.dateComponents([.month], from: Date()).month! @State var showSharingTemplate = false - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() @Environment(\.presentationMode) var presentationMode @AppStorage(UserDefaultsStore.Keys.moodTint.rawValue, store: GroupUserDefaults.groupDefaults) private var moodTint: MoodTints = .Default @@ -149,8 +149,8 @@ struct MonthTotalTemplate: View, SharingTemplate { HStack(alignment: .center) { Button(action: { let _image = self.image - self.shareImage.showFuckingSheet = true - self.shareImage.fuckingWrappedShrable = _image + self.shareImage.showSheet = true + self.shareImage.selectedShareImage = _image }, label: { Text("Share") .font(.title) @@ -158,8 +158,8 @@ struct MonthTotalTemplate: View, SharingTemplate { .foregroundColor(Color.white) .padding(.top, 20) }) - .sheet(isPresented: self.$shareImage.showFuckingSheet) { - if let uiImage = self.shareImage.fuckingWrappedShrable { + .sheet(isPresented: self.$shareImage.showSheet) { + if let uiImage = self.shareImage.selectedShareImage { ShareSheet(photo: uiImage) } } diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index 7818d4d..3073ea9 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -24,7 +24,7 @@ struct YearView: View { @EnvironmentObject var iapManager: IAPManager @StateObject public var viewModel: YearViewModel @StateObject private var filteredDays = DaysFilterClass.shared - @StateObject private var shareImage = StupidAssShareObservableObject() + @StateObject private var shareImage = ShareImageStateViewModel() @State private var trialWarningHidden = false @State private var showSubscriptionStore = false @@ -49,8 +49,8 @@ struct YearView: View { theme: theme, filteredDays: filteredDays.currentFilters, onShare: { image in - shareImage.fuckingWrappedShrable = image - shareImage.showFuckingSheet = true + shareImage.selectedShareImage = image + shareImage.showSheet = true } ) } @@ -100,8 +100,8 @@ struct YearView: View { .sheet(isPresented: $showSubscriptionStore) { FeelsSubscriptionStoreView() } - .sheet(isPresented: $shareImage.showFuckingSheet) { - if let uiImage = shareImage.fuckingWrappedShrable { + .sheet(isPresented: $shareImage.showSheet) { + if let uiImage = shareImage.selectedShareImage { ImageOnlyShareSheet(photo: uiImage) } } @@ -340,8 +340,8 @@ struct YearCard: View { // Month Labels HStack(spacing: 2) { - ForEach(months, id: \.self) { month in - Text(month) + ForEach(months.indices, id: \.self) { index in + Text(months[index]) .font(.system(size: 9, weight: .medium)) .foregroundColor(textColor.opacity(0.5)) .frame(maxWidth: .infinity) @@ -419,6 +419,19 @@ struct YearHeatmapCell: View { RoundedRectangle(cornerRadius: 2) .fill(cellColor) .aspectRatio(1, contentMode: .fit) + .accessibilityLabel(accessibilityDescription) + } + + private var accessibilityDescription: String { + if !isFiltered { + return "Filtered out" + } else if color == Mood.placeholder.color { + return "Empty" + } else if color == Mood.missing.color { + return "No mood logged" + } else { + return "Mood entry" + } } private var cellColor: Color {