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:
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2610"
|
||||
LastUpgradeVersion = "2620"
|
||||
wasCreatedForAppExtension = "YES"
|
||||
version = "2.0">
|
||||
<BuildAction
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: []
|
||||
|
||||
@@ -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
|
||||
|
||||
187
FeelsWidget2/WidgetDataProvider.swift
Normal file
187
FeelsWidget2/WidgetDataProvider.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
Shared/Models/CustomWidgetStateViewModel.swift
Normal file
13
Shared/Models/CustomWidgetStateViewModel.swift
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
244
Shared/Persisence/DataControllerProtocol.swift
Normal file
244
Shared/Persisence/DataControllerProtocol.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
34
Shared/Services/AppLogger.swift
Normal file
34
Shared/Services/AppLogger.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
173
Shared/Services/ImageCache.swift
Normal file
173
Shared/Services/ImageCache.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
119
Shared/Utilities/AccessibilityHelpers.swift
Normal file
119
Shared/Utilities/AccessibilityHelpers.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
class IconViewModel: ObservableObject {
|
||||
static let numberOfBGItems = 109
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user