Fix build errors, resolve all warnings, and improve code quality

Widget Extension Fixes:
- Create standalone WidgetDataProvider for widget data isolation
- Add WIDGET_EXTENSION compiler flag for conditional compilation
- Fix DataController references in widget-shared files
- Sync widget version numbers with main app (23, 1.0.2)
- Add WidgetBackground color to asset catalog

Warning Resolutions:
- Fix UIScreen.main deprecation in BGView and SharingListView
- Fix Text '+' concatenation deprecation in PurchaseButtonView and SettingsTabView
- Fix exhaustive switch in BiometricAuthManager (add .none case)
- Fix var to let in ExportService (3 instances)
- Fix unused result warning in NoteEditorView
- Fix ForEach duplicate ID warnings in MonthView and YearView

Code Quality Improvements:
- Wrap bypassSubscription in #if DEBUG for security
- Rename StupidAssCustomWidgetObservableObject to CustomWidgetStateViewModel
- Add @MainActor to IconViewModel
- Replace fatalError with graceful fallback in SharedModelContainer
- Add [weak self] to closures in DayViewViewModel
- Add OSLog-based AppLogger for production logging
- Add ImageCache with NSCache for memory efficiency
- Add AccessibilityHelpers with Reduce Motion support
- Create DataControllerProtocol for dependency injection
- Update .gitignore with secrets exclusions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-20 00:48:35 -06:00
parent 31a68b7102
commit 356ce9ea62
41 changed files with 1072 additions and 187 deletions

View File

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

8
.gitignore vendored
View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
LastUpgradeVersion = "2620"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2610"
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "1.000",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.000",
"green" : "0.000",
"red" : "0.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -34,7 +34,7 @@ struct VoteMoodIntent: AppIntent {
// Widget uses simplified mood logging since it can't access HealthKitManager/TipsManager
// Full side effects (HealthKit sync, TipKit) will run when main app opens via MoodLogger
DataController.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
WidgetDataProvider.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
// Store last voted date
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
@@ -53,24 +53,8 @@ struct VoteMoodIntent: AppIntent {
@MainActor
private func calculateCurrentStreak() -> 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: []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<MoodEntryModel>(
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<MoodEntryModel>(
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<MoodEntryModel>(
sortBy: [SortDescriptor(\.forDate, order: .forward)]
)
descriptor.fetchLimit = 1
return try? modelContext.fetch(descriptor).first
}
var latestEntry: MoodEntryModel? {
var descriptor = FetchDescriptor<MoodEntryModel>(
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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<V: Equatable>(_ 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)
}
}

View File

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

View File

@@ -7,6 +7,7 @@
import SwiftUI
@MainActor
class IconViewModel: ObservableObject {
static let numberOfBGItems = 109

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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