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

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