Files
Reflect/FeelsWidget2/FeelsVoteWidget.swift
Trey t 74dc289a3d Fix Live Activity streak messaging and mislabeled widget text
Show "Start your streak!" instead of "Don't break your streak!" when
streak count is zero, and fix small widget incorrectly labeling total
entries as "day streak".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:03:09 -06:00

433 lines
13 KiB
Swift

//
// FeelsVoteWidget.swift
// FeelsWidget
//
// Interactive widget for mood voting (iOS 17+)
//
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Widget Configuration
struct FeelsVoteWidget: Widget {
let kind: String = "FeelsVoteWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in
FeelsVoteWidgetEntryView(entry: entry)
}
.configurationDisplayName("Mood Vote")
.description("Quickly rate your mood for today")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// MARK: - Entry View
struct FeelsVoteWidgetEntryView: View {
@Environment(\.widgetFamily) var family
var entry: VoteWidgetProvider.Entry
var body: some View {
Group {
if entry.hasVotedToday {
// Already voted today - show stats (regardless of subscription status)
VotedStatsView(entry: entry)
} else {
// Not voted yet - show voting buttons
// If subscribed/in trial: buttons record votes
// If trial expired: buttons open app
VotingView(family: family, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
// MARK: - Voted Stats View (shown after voting)
struct VotedStatsView: View {
@Environment(\.widgetFamily) var family
let entry: VoteWidgetEntry
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
/// Returns "Today" if the voting date is today, otherwise returns formatted date like "Sun, Dec 28th"
private var votingDateString: String {
if Calendar.current.isDateInToday(entry.votingDate) {
return String(localized: "Today")
} else {
let dayFormatter = DateFormatter()
dayFormatter.dateFormat = "EEE" // "Sun"
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMM d" // "Dec 28"
let day = dayFormatter.string(from: entry.votingDate)
let date = dateFormatter.string(from: entry.votingDate)
return "\(day), \(date)"
}
}
var body: some View {
if family == .systemSmall {
smallLayout
} else {
mediumLayout
}
}
// MARK: - Small: Centered mood with checkmark and date
private var smallLayout: some View {
VStack(spacing: 8) {
if let mood = entry.todaysMood {
// Large centered mood icon
ZStack(alignment: .bottomTrailing) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(moodTint.color(forMood: mood))
// Checkmark badge
Image(systemName: "checkmark.circle.fill")
.font(.headline)
.foregroundColor(.green)
.background(Circle().fill(.white).frame(width: 14, height: 14))
.offset(x: 4, y: 4)
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "Mood logged: \(mood.strValue)"))
Text(votingDateString)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
if let stats = entry.stats {
Text("\(stats.totalEntries) entries")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
}
// MARK: - Medium: Mood + stats bar
private var mediumLayout: some View {
HStack(alignment: .top, spacing: 20) {
if let mood = entry.todaysMood {
// Left: Mood display
VStack(spacing: 6) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundColor(moodTint.color(forMood: mood))
.accessibilityLabel(mood.strValue)
Text(mood.widgetDisplayName)
.font(.subheadline.weight(.semibold))
.foregroundColor(moodTint.color(forMood: mood))
Text(votingDateString)
.font(.caption2)
.foregroundStyle(.secondary)
}
// Right: Stats with progress bar aligned under title
if let stats = entry.stats {
VStack(alignment: .leading, spacing: 10) {
Text("\(stats.totalEntries) entries")
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
// Mini mood breakdown
HStack(spacing: 6) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
let count = stats.moodCounts[mood, default: 0]
if count > 0 {
HStack(spacing: 2) {
Circle()
.fill(moodTint.color(forMood: mood))
.frame(width: 8, height: 8)
Text("\(count)")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
}
// Progress bar - aligned with title
GeometryReader { geo in
HStack(spacing: 1) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { m in
let percentage = stats.percentage(for: m)
if percentage > 0 {
RoundedRectangle(cornerRadius: 2)
.fill(moodTint.color(forMood: m))
.frame(width: max(4, geo.size.width * CGFloat(percentage) / 100))
}
}
}
}
.frame(height: 10)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding()
}
}
// MARK: - Non-Subscriber View
struct NonSubscriberView: View {
var body: some View {
Link(destination: URL(string: "feels://subscribe")!) {
VStack(spacing: 8) {
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundStyle(.pink)
Text("Track Your Mood")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.minimumScaleFactor(0.8)
Text("Tap to subscribe")
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(12)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.accessibilityLabel(String(localized: "Track Your Mood"))
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
}
}
// MARK: - Preview Helpers
private enum VoteWidgetPreviewHelpers {
static let sampleStats = MoodStats(
totalEntries: 30,
moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]
)
static let largeStats = MoodStats(
totalEntries: 100,
moodCounts: [.great: 35, .good: 40, .average: 15, .bad: 7, .horrible: 3]
)
}
// MARK: - Small Widget Previews
#Preview("Vote Small - Not Voted", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling today?"
)
}
#Preview("Vote Small - Voted Great", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Good", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Average", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Bad", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Horrible", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Non-Subscriber", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
)
}
// MARK: - Medium Widget Previews
#Preview("Vote Medium - Not Voted", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling today?"
)
}
#Preview("Vote Medium - Voted Great", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Good", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Average", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Bad", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Horrible", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Non-Subscriber", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
)
}