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

@@ -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<Entry>) -> ()) {
@MainActor func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
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()
}
}
}