Files
Reflect/ReflectWidget/ReflectVoteWidget.swift
Trey t 0442eab1f8 Rebrand entire project from Feels to Reflect
Complete rename across all bundle IDs, App Groups, CloudKit containers,
StoreKit product IDs, data store filenames, URL schemes, logger subsystems,
Swift identifiers, user-facing strings (7 languages), file names, directory
names, Xcode project, schemes, assets, and documentation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 11:47:16 -06:00

433 lines
13 KiB
Swift

//
// ReflectVoteWidget.swift
// ReflectWidget
//
// Interactive widget for mood voting (iOS 17+)
//
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Widget Configuration
struct ReflectVoteWidget: Widget {
let kind: String = "ReflectVoteWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in
ReflectVoteWidgetEntryView(entry: entry)
}
.configurationDisplayName("Mood Vote")
.description("Quickly rate your mood for today")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
// MARK: - Entry View
struct ReflectVoteWidgetEntryView: 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: "reflect://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) {
ReflectVoteWidget()
} 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) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Good", as: .systemSmall) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Average", as: .systemSmall) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Bad", as: .systemSmall) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Horrible", as: .systemSmall) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Non-Subscriber", as: .systemSmall) {
ReflectVoteWidget()
} 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) {
ReflectVoteWidget()
} 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) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Good", as: .systemMedium) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Average", as: .systemMedium) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Bad", as: .systemMedium) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Horrible", as: .systemMedium) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Non-Subscriber", as: .systemMedium) {
ReflectVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
votingDate: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
)
}