Add interactive widget voting and fix warnings/bugs

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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-10 16:23:12 -06:00
parent aaaf04f05e
commit f822927e98
20 changed files with 317 additions and 220 deletions

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
import Foundation
extension Date: RawRepresentable {
extension Date: @retroactive RawRepresentable {
public var rawValue: String {
self.timeIntervalSinceReferenceDate.description
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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..<sortedEntries.count {
let currentDate = sortedEntries[i].forDate
let previousDate = sortedEntries[i-1].forDate
let dayDiff = calendar.dateComponents([.day], from: currentDate, to: previousDate).day ?? 0
let dayDiff = calendar.dateComponents([.day], from: previousDate, to: currentDate).day ?? 0
if dayDiff == 1 {
tempStreak += 1
} else {

View File

@@ -58,29 +58,27 @@ struct MainTabView: View {
})
})
.onAppear(perform: {
switch theme {
case .system:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .unspecified
case .iFeel:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .unspecified
case .dark:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .dark
case .light:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .light
}
})
.onChange(of: theme, perform: { value in
switch theme {
case .system:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .unspecified
case .iFeel:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .unspecified
case .dark:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .dark
case .light:
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .light
}
applyTheme(theme)
})
.onChange(of: theme) { _, newTheme in
applyTheme(newTheme)
}
}
private func applyTheme(_ theme: Theme) {
let style: UIUserInterfaceStyle
switch theme {
case .system, .iFeel:
style = .unspecified
case .dark:
style = .dark
case .light:
style = .light
}
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first else { return }
window.overrideUserInterfaceStyle = style
}
}

View File

@@ -112,6 +112,7 @@ struct SettingsView: View {
defer { selectedFile.stopAccessingSecurityScopedResource() }
var rows = input.components(separatedBy: "\n")
guard !rows.isEmpty else { return }
rows.removeFirst()
for row in rows {
let stripped = row.replacingOccurrences(of: " +0000", with: "")
@@ -119,10 +120,12 @@ struct SettingsView: View {
if columns.count != 7 {
continue
}
let forDate = dateFormatter.date(from: columns[3])!
let moodValue = Int(columns[4])!
guard let forDate = dateFormatter.date(from: columns[3]),
let moodValue = Int(columns[4]) else {
continue
}
let mood = Mood(rawValue: moodValue) ?? .missing
let entryType = EntryType(rawValue: Int(columns[2])!) ?? .listView
let entryType = EntryType(rawValue: Int(columns[2]) ?? 0) ?? .listView
DataController.shared.add(mood: mood, forDate: forDate, entryType: entryType)
}
@@ -357,7 +360,7 @@ struct SettingsView: View {
Text(String(localized: "settings_use_cloudkit_title"))
.foregroundColor(textColor)
})
.onChange(of: useCloudKit) { newValue in
.onChange(of: useCloudKit) { _, newValue in
EventLogger.log(event: "toggle_use_cloudkit", withData: ["value": newValue])
}
.padding()
@@ -391,7 +394,7 @@ struct SettingsView: View {
VStack {
Toggle(String(localized: "settings_use_delete_enable"),
isOn: $deleteEnabled)
.onChange(of: deleteEnabled) { newValue in
.onChange(of: deleteEnabled) { _, newValue in
EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue])
}
.foregroundColor(textColor)

View File

@@ -49,7 +49,7 @@ struct AllMoodsTotalTemplate: View, SharingTemplate {
$0.percent > $1.percent
})
} else {
fatalError("no data")
entries = []
}
}