From f822927e98061d991601c75b38674d5e652a0f13 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 10 Dec 2025 16:23:12 -0600 Subject: [PATCH] Add interactive widget voting and fix warnings/bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Widget Features: - Add inline voting to timeline widget when no entry exists for today - Show random prompt from notification strings in voting mode - Update vote widget to use simple icon style for selection - Make stats bar full width in voted state view - Add Localizable.strings to widget extension target Bug Fixes: - Fix inverted date calculation in InsightsViewModel streak logic - Replace force unwraps with safe optional handling in widgets - Replace fatalError calls with graceful error handling - Fix CSV import safety in SettingsView Warning Fixes: - Add @retroactive to Color and Date extension conformances - Update deprecated onChange(of:perform:) to new syntax - Replace deprecated applicationIconBadgeNumber with setBadgeCount - Replace deprecated UIApplication.shared.windows API - Add @preconcurrency for Swift 6 protocol conformances - Add missing widget family cases to switch statement - Remove unused variables and #warning directives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- Feels.xcodeproj/project.pbxproj | 2 + FeelsWidget2/FeelsVoteWidget.swift | 102 ++++----- FeelsWidget2/FeelsWidget.swift | 205 ++++++++++++++---- Shared/AppDelegate.swift | 4 +- Shared/Color+Codable.swift | 4 +- Shared/Date+Extensions.swift | 2 +- Shared/FeelsApp.swift | 10 +- Shared/IAPManager.swift | 5 - Shared/Models/UserDefaultsStore.swift | 50 ++--- Shared/Persisence/DataControllerDELETE.swift | 6 +- Shared/ShowBasedOnVoteLogics.swift | 2 - .../Views/CustomIcon/CreateWidgetView.swift | 32 +-- .../Views/CustomizeView/CustomizeView.swift | 2 +- .../SubViews/TintPickerView.swift | 28 +-- Shared/Views/DayView/DayViewViewModel.swift | 2 +- Shared/Views/HeaderStatsView.swift | 10 +- .../InsightsView/InsightsViewModel.swift | 14 +- Shared/Views/MainTabView.swift | 42 ++-- Shared/Views/SettingsView/SettingsView.swift | 13 +- .../AllMoodsTotalTemplate.swift | 2 +- 20 files changed, 317 insertions(+), 220 deletions(-) diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 71ac81e..a23f57b 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1C0DAB45279DB0FB003B1F21 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB47279DB0FB003B1F21 /* Localizable.strings */; }; + 1C0DAB49279DB0FB003B1F22 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 1C0DAB47279DB0FB003B1F21 /* Localizable.strings */; }; 1C2618FA2795E41D00FDC148 /* Charts in Frameworks */ = {isa = PBXBuildFile; productRef = 1C2618F92795E41D00FDC148 /* Charts */; }; 1C747CC9279F06EB00762CBD /* CloudKitSyncMonitor in Frameworks */ = {isa = PBXBuildFile; productRef = 1C747CC8279F06EB00762CBD /* CloudKitSyncMonitor */; }; 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CB4D09F28787D8A00902A56 /* StoreKit.framework */; }; @@ -436,6 +437,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1C0DAB49279DB0FB003B1F22 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift index 2376b31..40f15d8 100644 --- a/FeelsWidget2/FeelsVoteWidget.swift +++ b/FeelsWidget2/FeelsVoteWidget.swift @@ -52,14 +52,16 @@ struct VoteWidgetProvider: TimelineProvider { func placeholder(in context: Context) -> VoteWidgetEntry { // Show sample "already voted" state for widget picker preview let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) - return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats) + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) } func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { // Show sample data for widget picker preview if context.isPreview { let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) - let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats) + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) completion(entry) return } @@ -115,12 +117,16 @@ struct VoteWidgetProvider: TimelineProvider { } } + // Get random prompt text for voting view + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + return VoteWidgetEntry( date: Date(), hasSubscription: hasSubscription, hasVotedToday: hasVotedToday, todaysMood: todaysMood, - stats: stats + stats: stats, + promptText: promptText ) } } @@ -145,6 +151,7 @@ struct VoteWidgetEntry: TimelineEntry { let hasVotedToday: Bool let todaysMood: Mood? let stats: MoodStats? + let promptText: String } // MARK: - Widget Views @@ -161,7 +168,7 @@ struct FeelsVoteWidgetEntryView: View { VotedStatsView(entry: entry) } else { // Show voting buttons - VotingView(family: family) + VotingView(family: family, promptText: entry.promptText) } } else { // Non-subscriber view - tap to open app @@ -176,39 +183,9 @@ struct FeelsVoteWidgetEntryView: View { struct VotingView: View { let family: WidgetFamily + let promptText: String let moods: [Mood] = [.horrible, .bad, .average, .good, .great] - var body: some View { - VStack(spacing: 8) { - Text("How are you feeling?") - .font(.headline) - .foregroundStyle(.primary) - - if family == .systemSmall { - // Compact layout for small widget - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - ForEach(moods, id: \.rawValue) { mood in - MoodButton(mood: mood, isCompact: true) - } - } - } else { - // Horizontal layout for medium/large - HStack(spacing: 4) { - ForEach(moods, id: \.rawValue) { mood in - MoodButton(mood: mood, isCompact: false) - .frame(maxWidth: .infinity) - } - } - } - } - .padding() - } -} - -struct MoodButton: View { - let mood: Mood - let isCompact: Bool - private var moodTint: MoodTintable.Type { UserDefaultsStore.moodTintable() } @@ -218,23 +195,28 @@ struct MoodButton: View { } var body: some View { - Button(intent: VoteMoodIntent(mood: mood)) { - VStack(spacing: 4) { - moodImages.icon(forMood: mood) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: isCompact ? 28 : 36, height: isCompact ? 28 : 36) - .foregroundColor(moodTint.color(forMood: mood)) + VStack(spacing: 12) { + Text(promptText) + .font(.headline) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) - if !isCompact { - Text(mood.widgetDisplayName) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.7) + HStack(spacing: 8) { + ForEach(moods, id: \.rawValue) { mood in + Button(intent: VoteMoodIntent(mood: mood)) { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: family == .systemSmall ? 36 : 44, height: family == .systemSmall ? 36 : 44) + .foregroundColor(moodTint.color(forMood: mood)) + } + .buttonStyle(.plain) } } } + .padding() } } @@ -284,17 +266,19 @@ struct VotedStatsView: View { .font(.caption) .foregroundStyle(.secondary) - HStack(spacing: 4) { - ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in - let percentage = stats.percentage(for: mood) - if percentage > 0 { - RoundedRectangle(cornerRadius: 2) - .fill(moodTint.color(forMood: mood)) - .frame(width: max(4, CGFloat(percentage) * 0.8)) + GeometryReader { geo in + HStack(spacing: 2) { + ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in + let percentage = stats.percentage(for: mood) + if percentage > 0 { + RoundedRectangle(cornerRadius: 2) + .fill(moodTint.color(forMood: mood)) + .frame(width: max(4, geo.size.width * CGFloat(percentage) / 100)) + } } } } - .frame(height: 8) + .frame(height: 12) } } } @@ -347,7 +331,7 @@ struct FeelsVoteWidget: Widget { #Preview(as: .systemSmall) { FeelsVoteWidget() } timeline: { - VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil) - VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1])) - VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil) + VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?") + VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]), promptText: "") + VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "") } diff --git a/FeelsWidget2/FeelsWidget.swift b/FeelsWidget2/FeelsWidget.swift index 3576f57..af491d7 100644 --- a/FeelsWidget2/FeelsWidget.swift +++ b/FeelsWidget2/FeelsWidget.swift @@ -86,7 +86,7 @@ struct TimeLineCreator { } } -struct Provider: IntentTimelineProvider { +struct Provider: @preconcurrency IntentTimelineProvider { let timeLineCreator = TimeLineCreator() /* @@ -107,25 +107,52 @@ struct Provider: IntentTimelineProvider { } else { timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)) } + let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus() let entry = SimpleEntry(date: Date(), configuration: ConfigurationIntent(), - timeLineViews: timeLineViews) + timeLineViews: timeLineViews, + hasSubscription: hasSubscription, + hasVotedToday: hasVotedToday, + promptText: promptText) completion(entry) } - - func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + + @MainActor func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus() let entry = SimpleEntry(date: Calendar.current.date(byAdding: .second, value: 15, to: Date())!, configuration: ConfigurationIntent(), - timeLineViews: nil) - + timeLineViews: nil, + hasSubscription: hasSubscription, + hasVotedToday: hasVotedToday, + promptText: promptText) + let midNightEntry = SimpleEntry(date: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date())!, configuration: ConfigurationIntent(), - timeLineViews: nil) - + timeLineViews: nil, + hasSubscription: hasSubscription, + hasVotedToday: hasVotedToday, + promptText: promptText) + let date = Calendar.current.date(byAdding: .second, value: 10, to: Date())! let timeline = Timeline(entries: [entry, midNightEntry], policy: .after(date)) completion(timeline) } + + @MainActor + private func checkSubscriptionAndVoteStatus() -> (hasSubscription: Bool, hasVotedToday: Bool, promptText: String) { + let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) + + 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) ?? dayStart + + let todayEntry = DataController.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 + + return (hasSubscription, hasVotedToday, promptText) + } } struct SimpleEntry: TimelineEntry { @@ -133,12 +160,18 @@ struct SimpleEntry: TimelineEntry { let configuration: ConfigurationIntent let timeLineViews: [WatchTimelineView]? let showStats: Bool - - init(date: Date, configuration: ConfigurationIntent, timeLineViews: [WatchTimelineView]?, showStats: Bool = false) { + let hasSubscription: Bool + let hasVotedToday: Bool + let promptText: String + + init(date: Date, configuration: ConfigurationIntent, timeLineViews: [WatchTimelineView]?, showStats: Bool = false, hasSubscription: Bool = false, hasVotedToday: Bool = true, promptText: String = "") { self.date = date self.configuration = configuration self.timeLineViews = timeLineViews self.showStats = showStats + self.hasSubscription = hasSubscription + self.hasVotedToday = hasVotedToday + self.promptText = promptText } } @@ -146,13 +179,16 @@ struct SimpleEntry: TimelineEntry { struct FeelsWidgetEntryView : View { @Environment(\.sizeCategory) var sizeCategory @Environment(\.widgetFamily) var family - + var entry: Provider.Entry - + + private var showVotingForToday: Bool { + entry.hasSubscription && !entry.hasVotedToday + } + @ViewBuilder var body: some View { - ZStack { - Color(UIColor.systemBackground) + Group { switch family { case .systemSmall: SmallWidgetView(entry: entry) @@ -162,10 +198,13 @@ struct FeelsWidgetEntryView : View { LargeWidgetView(entry: entry) case .systemExtraLarge: LargeWidgetView(entry: entry) + case .accessoryCircular, .accessoryRectangular, .accessoryInline: + SmallWidgetView(entry: entry) @unknown default: - fatalError() + MediumWidgetView(entry: entry) } } + .containerBackground(showVotingForToday ? Color.clear : Color(UIColor.systemBackground), for: .widget) } } @@ -181,7 +220,9 @@ struct SmallWidgetView: View { let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable() return view.color != moodTint.color(forMood: .missing) } - timeLineView = hasRealData ? [realData.first!] : [TimeLineCreator.createSampleViews(count: 1).first!] + if let firstView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first { + timeLineView = [firstView] + } } var body: some View { @@ -204,6 +245,10 @@ struct MediumWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() + private var showVotingForToday: Bool { + entry.hasSubscription && !entry.hasVotedToday + } + init(entry: Provider.Entry) { self.entry = entry let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5)) @@ -219,13 +264,15 @@ struct MediumWidgetView: View { VStack { Spacer() - TimeHeaderView(startDate: timeLineView.first!.date, endDate: timeLineView.last!.date) - .frame(minWidth: 0, maxWidth: .infinity) - .multilineTextAlignment(.leading) + if !showVotingForToday, let first = timeLineView.first, let last = timeLineView.last { + TimeHeaderView(startDate: first.date, endDate: last.date) + .frame(minWidth: 0, maxWidth: .infinity) + .multilineTextAlignment(.leading) + } - TimeBodyView(group: timeLineView) - .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) - .frame(minHeight: 0, maxHeight: 55) + TimeBodyView(group: timeLineView, showVotingForToday: showVotingForToday, promptText: entry.promptText) + .clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous)) + .frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55) .padding() Spacer() @@ -237,6 +284,10 @@ struct LargeWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() + private var showVotingForToday: Bool { + entry.hasSubscription && !entry.hasVotedToday + } + init(entry: Provider.Entry) { self.entry = entry let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10)) @@ -248,33 +299,52 @@ struct LargeWidgetView: View { timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10) } - var firstGroup: ([WatchTimelineView], String) { - return (Array(self.timeLineView.prefix(5)), UUID().uuidString) + var firstGroup: [WatchTimelineView] { + return Array(self.timeLineView.prefix(5)) } - var secondGroup: ([WatchTimelineView], String) { - return (Array(self.timeLineView.suffix(5)), UUID().uuidString) + var secondGroup: [WatchTimelineView] { + return Array(self.timeLineView.suffix(5)) } var body: some View { VStack { Spacer() - ForEach([firstGroup, secondGroup], id: \.1) { group in - VStack { - Spacer() + // First row (includes today - may show voting) + VStack { + Spacer() - TimeHeaderView(startDate: group.0.first!.date, endDate: group.0.last!.date) + if !showVotingForToday, let first = firstGroup.first, let last = firstGroup.last { + TimeHeaderView(startDate: first.date, endDate: last.date) .frame(minWidth: 0, maxWidth: .infinity) .multilineTextAlignment(.leading) - - TimeBodyView(group: group.0) - .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) - .frame(minHeight: 0, maxHeight: 55) - .padding() - - Spacer() } + + TimeBodyView(group: firstGroup, showVotingForToday: showVotingForToday, promptText: entry.promptText) + .clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous)) + .frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55) + .padding() + + Spacer() + } + + // Second row (older entries - never show voting) + VStack { + Spacer() + + if let first = secondGroup.first, let last = secondGroup.last { + TimeHeaderView(startDate: first.date, endDate: last.date) + .frame(minWidth: 0, maxWidth: .infinity) + .multilineTextAlignment(.leading) + } + + TimeBodyView(group: secondGroup, showVotingForToday: false) + .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) + .frame(minHeight: 0, maxHeight: 55) + .padding() + + Spacer() } Spacer() @@ -376,16 +446,63 @@ struct TimeHeaderView: View { struct TimeBodyView: View { let group: [WatchTimelineView] - + var showVotingForToday: Bool = false + var promptText: String = "" + var body: some View { - ZStack { - Color(UIColor.secondarySystemBackground) - HStack { - ForEach(group) { watchView in - EntryCard(timeLineView: watchView) + if showVotingForToday { + // Show voting view without extra background container + InlineVotingView(promptText: promptText) + .padding() + } else { + ZStack { + Color(UIColor.secondarySystemBackground) + HStack(spacing: 4) { + ForEach(group) { watchView in + EntryCard(timeLineView: watchView) + } + } + .padding() + } + } + } +} + +// MARK: - Inline Voting View (compact mood buttons for timeline widget) + +struct InlineVotingView: View { + let promptText: String + let moods: [Mood] = [.horrible, .bad, .average, .good, .great] + + private var moodTint: MoodTintable.Type { + UserDefaultsStore.moodTintable() + } + + private var moodImages: MoodImagable.Type { + UserDefaultsStore.moodMoodImagable() + } + + var body: some View { + VStack(spacing: 8) { + Text(promptText) + .font(.subheadline) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.7) + + HStack(spacing: 8) { + ForEach(moods, id: \.rawValue) { mood in + Button(intent: VoteMoodIntent(mood: mood)) { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 44, height: 44) + .foregroundColor(moodTint.color(forMood: mood)) + } + .buttonStyle(.plain) } } - .padding() } } } diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift index de077e0..c1b18ae 100644 --- a/Shared/AppDelegate.swift +++ b/Shared/AppDelegate.swift @@ -45,7 +45,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { } } -extension AppDelegate: UNUserNotificationCenterDelegate { +extension AppDelegate: @preconcurrency UNUserNotificationCenterDelegate { func requestAuthorization() { } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { @@ -68,7 +68,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate { case .great: DataController.shared.add(mood: .great, forDate: date, entryType: .notification) } - UIApplication.shared.applicationIconBadgeNumber = 0 + UNUserNotificationCenter.current().setBadgeCount(0) } WidgetCenter.shared.reloadAllTimelines() completionHandler() diff --git a/Shared/Color+Codable.swift b/Shared/Color+Codable.swift index b5ff0d0..4ad023d 100644 --- a/Shared/Color+Codable.swift +++ b/Shared/Color+Codable.swift @@ -10,7 +10,7 @@ import SwiftUI // Inspired by https://cocoacasts.com/from-hex-to-uicolor-and-back-in-swift // Make Color codable. This includes support for transparency. // See https://www.digitalocean.com/community/tutorials/css-hex-code-colors-alpha-values -extension Color: Codable { +extension Color: @retroactive Codable { init(hex: String) { let rgba = hex.toRGBA() @@ -99,7 +99,7 @@ extension String { } } -extension Color: RawRepresentable { +extension Color: @retroactive RawRepresentable { // TODO: Sort out alpha public init?(rawValue: Int) { let red = Double((rawValue & 0xFF0000) >> 16) / 0xFF diff --git a/Shared/Date+Extensions.swift b/Shared/Date+Extensions.swift index 6be0fb4..a2923b6 100644 --- a/Shared/Date+Extensions.swift +++ b/Shared/Date+Extensions.swift @@ -7,7 +7,7 @@ import Foundation -extension Date: RawRepresentable { +extension Date: @retroactive RawRepresentable { public var rawValue: String { self.timeIntervalSinceReferenceDate.description } diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index c33c46f..7608be9 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -25,7 +25,7 @@ struct FeelsApp: App { BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in BGTask.runFillInMissingDatesTask(task: task as! BGProcessingTask) } - UIApplication.shared.applicationIconBadgeNumber = 0 + UNUserNotificationCenter.current().setBadgeCount(0) } var body: some Scene { @@ -46,14 +46,14 @@ struct FeelsApp: App { showSubscriptionFromWidget = true } } - }.onChange(of: scenePhase) { phase in - if phase == .background { + }.onChange(of: scenePhase) { _, newPhase in + if newPhase == .background { //BGTask.scheduleBackgroundProcessing() WidgetCenter.shared.reloadAllTimelines() } - if phase == .active { - UIApplication.shared.applicationIconBadgeNumber = 0 + if newPhase == .active { + UNUserNotificationCenter.current().setBadgeCount(0) // Check subscription status on each app launch Task { await iapManager.checkSubscriptionStatus() diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index 95900fc..b0b4751 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -167,8 +167,6 @@ class IAPManager: ObservableObject { } private func checkForActiveSubscription() async -> Bool { - var foundActiveSubscription = false - for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } @@ -178,9 +176,6 @@ class IAPManager: ObservableObject { // Check if this is one of our subscription products guard productIdentifiers.contains(transaction.productID) else { continue } - // Found an active subscription - foundActiveSubscription = true - // Get the product for this transaction currentProduct = availableProducts.first { $0.id == transaction.productID } diff --git a/Shared/Models/UserDefaultsStore.swift b/Shared/Models/UserDefaultsStore.swift index b4b6243..f08a934 100644 --- a/Shared/Models/UserDefaultsStore.swift +++ b/Shared/Models/UserDefaultsStore.swift @@ -62,14 +62,15 @@ class UserDefaultsStore { } } + @discardableResult static func saveOnboarding(onboardingData: OnboardingData) -> OnboardingData { do { let data = try JSONEncoder().encode(onboardingData) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.savedOnboardingData.rawValue) - return UserDefaultsStore.getOnboarding() } catch { - fatalError("error saving") + print("Error saving onboarding: \(error)") } + return UserDefaultsStore.getOnboarding() } static func moodMoodImagable() -> MoodImagable.Type { @@ -114,21 +115,21 @@ class UserDefaultsStore { return model } else { GroupUserDefaults.groupDefaults.removeObject(forKey: UserDefaultsStore.Keys.customWidget.rawValue) - + let widget = CustomWidgetModel.randomWidget widget.isSaved = true let widgets = [widget] - let data = try! JSONEncoder().encode(widgets) + + guard let data = try? JSONEncoder().encode(widgets) else { + return widgets + } GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) - if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data, - let models = try? JSONDecoder().decode([CustomWidgetModel].self, from: data) { - let sorted = models.sorted(by: { - $0.createdDate < $1.createdDate - }) - return sorted + if let savedData = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customWidget.rawValue) as? Data, + let models = try? JSONDecoder().decode([CustomWidgetModel].self, from: savedData) { + return models.sorted { $0.createdDate < $1.createdDate } } else { - fatalError("error getting widgets") + return widgets } } } @@ -160,12 +161,12 @@ class UserDefaultsStore { }) let data = try JSONEncoder().encode(existingWidgets) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) - return UserDefaultsStore.getCustomWidgets() } catch { - fatalError("error saving") + print("Error saving custom widget: \(error)") } + return UserDefaultsStore.getCustomWidgets() } - + @discardableResult static func deleteCustomWidget(withUUID uuid: String) -> [CustomWidgetModel] { do { @@ -184,22 +185,18 @@ class UserDefaultsStore { existingWidgets.append(widget) } - if let _ = existingWidgets.first(where: { - $0.inUse == true - }) {} else { - if let first = existingWidgets.first { - first.inUse = true - } + if existingWidgets.first(where: { $0.inUse == true }) == nil { + existingWidgets.first?.inUse = true } - + let data = try JSONEncoder().encode(existingWidgets) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customWidget.rawValue) - return UserDefaultsStore.getCustomWidgets() } catch { - fatalError("error saving") + print("Error deleting custom widget: \(error)") } + return UserDefaultsStore.getCustomWidgets() } - + static func getCustomMoodTint() -> SavedMoodTint { if let data = GroupUserDefaults.groupDefaults.object(forKey: UserDefaultsStore.Keys.customMoodTint.rawValue) as? Data{ do { @@ -226,11 +223,10 @@ class UserDefaultsStore { do { let data = try JSONEncoder().encode(customTint) GroupUserDefaults.groupDefaults.set(data, forKey: UserDefaultsStore.Keys.customMoodTint.rawValue) - return UserDefaultsStore.getCustomMoodTint() } catch { - print(error) - fatalError("error saving") + print("Error saving custom mood tint: \(error)") } + return UserDefaultsStore.getCustomMoodTint() } @discardableResult diff --git a/Shared/Persisence/DataControllerDELETE.swift b/Shared/Persisence/DataControllerDELETE.swift index 2c21974..ce6a3f6 100644 --- a/Shared/Persisence/DataControllerDELETE.swift +++ b/Shared/Persisence/DataControllerDELETE.swift @@ -14,12 +14,12 @@ extension DataController { try modelContext.delete(model: MoodEntryModel.self) saveAndRunDataListeners() } catch { - fatalError("Failed to clear database: \(error)") + print("Failed to clear database: \(error)") } } func deleteLast(numberOfEntries: Int) { - let startDate = Calendar.current.date(byAdding: .day, value: -numberOfEntries, to: Date())! + guard let startDate = Calendar.current.date(byAdding: .day, value: -numberOfEntries, to: Date()) else { return } let entries = getData(startDate: startDate, endDate: Date(), includedDays: []) for entry in entries { @@ -29,7 +29,7 @@ extension DataController { } func deleteRandomFromLast(numberOfEntries: Int) { - let startDate = Calendar.current.date(byAdding: .day, value: -numberOfEntries, to: Date())! + guard let startDate = Calendar.current.date(byAdding: .day, value: -numberOfEntries, to: Date()) else { return } let entries = getData(startDate: startDate, endDate: Date(), includedDays: []) for entry in entries where Bool.random() { diff --git a/Shared/ShowBasedOnVoteLogics.swift b/Shared/ShowBasedOnVoteLogics.swift index 1a88a0b..2e5fc16 100644 --- a/Shared/ShowBasedOnVoteLogics.swift +++ b/Shared/ShowBasedOnVoteLogics.swift @@ -61,8 +61,6 @@ class ShowBasedOnVoteLogics { static public func isMissingCurrentVote(onboardingData: OnboardingData) -> Bool { let startDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: onboardingData).startOfDay - let endDate = startDate.endOfDay - let entry = DataController.shared.getEntry(byDate: startDate) return entry == nil || entry?.mood == .missing } diff --git a/Shared/Views/CustomIcon/CreateWidgetView.swift b/Shared/Views/CustomIcon/CreateWidgetView.swift index 9961a15..0341526 100644 --- a/Shared/Views/CustomIcon/CreateWidgetView.swift +++ b/Shared/Views/CustomIcon/CreateWidgetView.swift @@ -176,51 +176,51 @@ struct CreateWidgetView: View { VStack(alignment: .center) { Text(String(localized: "create_widget_background_color")) ColorPicker("", selection: $customWidget.bgColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.bgColor) { EventLogger.log(event: "create_widget_view_update_background_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) - + VStack(alignment: .center) { Text(String(localized: "create_widget_inner_color")) ColorPicker("", selection: $customWidget.innerColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.innerColor) { EventLogger.log(event: "create_widget_view_update_inner_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) - + VStack(alignment: .center) { Text(String(localized: "create_widget_face_outline_color")) ColorPicker("", selection: $customWidget.circleStrokeColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.circleStrokeColor) { EventLogger.log(event: "create_widget_view_update_outline_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) } - + HStack(spacing: 0) { VStack(alignment: .center) { Text(String(localized: "create_widget_view_left_eye_color")) ColorPicker("", selection: $customWidget.leftEyeColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.leftEyeColor) { EventLogger.log(event: "create_widget_view_update_left_eye_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) - + VStack(alignment: .center) { Text(String(localized: "create_widget_view_right_eye_color")) ColorPicker("", selection: $customWidget.rightEyeColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.rightEyeColor) { EventLogger.log(event: "create_widget_view_update_right_eye_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) @@ -228,9 +228,9 @@ struct CreateWidgetView: View { VStack(alignment: .center) { Text(String(localized: "create_widget_view_mouth_color")) ColorPicker("", selection: $customWidget.mouthColor) - .onChange(of: customWidget.mouthColor, perform: { newValue in + .onChange(of: customWidget.mouthColor) { EventLogger.log(event: "create_widget_view_update_mouth_color") - }) + } .labelsHidden() } .frame(minWidth: 0, maxWidth: .infinity) diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index 0de20ce..4005e88 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -343,7 +343,7 @@ struct TintPickerCompact: View { ForEach(0..<5, id: \.self) { index in ColorPicker("", selection: colorBinding(for: index)) .labelsHidden() - .onChange(of: colorBinding(for: index).wrappedValue) { _ in + .onChange(of: colorBinding(for: index).wrappedValue) { saveCustomMoodTint() } } diff --git a/Shared/Views/CustomizeView/SubViews/TintPickerView.swift b/Shared/Views/CustomizeView/SubViews/TintPickerView.swift index 41185e1..2f75642 100644 --- a/Shared/Views/CustomizeView/SubViews/TintPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/TintPickerView.swift @@ -60,39 +60,39 @@ struct TintPickerView: View { HStack { ColorPicker("", selection: $customMoodTint.colorOne) - .onChange(of: customMoodTint.colorOne, perform: { _ in + .onChange(of: customMoodTint.colorOne) { saveCustomMoodTint() - }) + } .labelsHidden() .frame(minWidth: 0, maxWidth: .infinity) - + ColorPicker("", selection: $customMoodTint.colorTwo) .labelsHidden() .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorTwo, perform: { _ in + .onChange(of: customMoodTint.colorTwo) { saveCustomMoodTint() - }) - + } + ColorPicker("", selection: $customMoodTint.colorThree) .labelsHidden() .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorThree, perform: { _ in + .onChange(of: customMoodTint.colorThree) { saveCustomMoodTint() - }) - + } + ColorPicker("", selection: $customMoodTint.colorFour) .labelsHidden() .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorFour, perform: { _ in + .onChange(of: customMoodTint.colorFour) { saveCustomMoodTint() - }) - + } + ColorPicker("", selection: $customMoodTint.colorFive) .labelsHidden() .frame(minWidth: 0, maxWidth: .infinity) - .onChange(of: customMoodTint.colorFive, perform: { _ in + .onChange(of: customMoodTint.colorFive) { saveCustomMoodTint() - }) + } } .background( Color.clear diff --git a/Shared/Views/DayView/DayViewViewModel.swift b/Shared/Views/DayView/DayViewViewModel.swift index 1d4b456..52169f8 100644 --- a/Shared/Views/DayView/DayViewViewModel.swift +++ b/Shared/Views/DayView/DayViewViewModel.swift @@ -65,7 +65,7 @@ class DayViewViewModel: ObservableObject { public func update(entry: MoodEntryModel, toMood mood: Mood) { if !DataController.shared.update(entryDate: entry.forDate, withMood: mood) { - #warning("show error") + print("Failed to update mood entry") } } diff --git a/Shared/Views/HeaderStatsView.swift b/Shared/Views/HeaderStatsView.swift index 679d0cf..d2bf222 100644 --- a/Shared/Views/HeaderStatsView.swift +++ b/Shared/Views/HeaderStatsView.swift @@ -19,9 +19,7 @@ struct HeaderStatsView : UIViewRepresentable { init(fakeData: Bool, backDays: Int, moodTint: [Color], textColor: Color) { self.moodTints = moodTint self.textColor = textColor - guard moodTints.count == 5 else { - fatalError("mood tint count dont match") - } + assert(moodTints.count == 5, "mood tint count should be 5") self.tmpHolderToMakeViewDiffefrent = Color.random() entries = [BarChartDataEntry]() @@ -30,8 +28,10 @@ struct HeaderStatsView : UIViewRepresentable { if fakeData { moodEntries = DataController.shared.randomEntries(count: 10) } else { - var daysAgo = Calendar.current.date(byAdding: .day, value: -backDays, to: Date())! - daysAgo = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: daysAgo)! + guard let daysAgoDate = Calendar.current.date(byAdding: .day, value: -backDays, to: Date()), + let daysAgo = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: daysAgoDate) else { + return + } moodEntries = DataController.shared.getData(startDate: daysAgo, endDate: Date(), includedDays: [1,2,3,4,5,6,7]) } diff --git a/Shared/Views/InsightsView/InsightsViewModel.swift b/Shared/Views/InsightsView/InsightsViewModel.swift index ac0191f..38512e6 100644 --- a/Shared/Views/InsightsView/InsightsViewModel.swift +++ b/Shared/Views/InsightsView/InsightsViewModel.swift @@ -990,16 +990,20 @@ class InsightsViewModel: ObservableObject { var tempStreak = 1 let today = calendar.startOfDay(for: Date()) - let mostRecent = sortedEntries.first!.forDate - if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: calendar.date(byAdding: .day, value: -1, to: today)!) { + guard let mostRecent = sortedEntries.first?.forDate, + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) else { + return (0, 0) + } + + if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) { currentStreak = 1 - var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)! + var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent) ?? mostRecent for entry in sortedEntries.dropFirst() { let entryDate = entry.forDate if calendar.isDate(entryDate, inSameDayAs: checkDate) { currentStreak += 1 - checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)! + checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate) ?? checkDate } else { break } @@ -1009,7 +1013,7 @@ class InsightsViewModel: ObservableObject { for i in 1.. $1.percent }) } else { - fatalError("no data") + entries = [] } }