- Wrap 30+ production print() statements in #if DEBUG guards across 18 files - Add VoiceOver labels, hints, and traits to Watch app, Live Activities, widgets - Add .accessibilityAddTraits(.isButton) to 15+ onTapGesture views - Add text alternatives for color-only indicators (progress dots, mood circles) - Localize raw string literals in NoteEditorView, EntryDetailView, widgets - Replace 25+ silent try? with do/catch + AppLogger error logging - Replace hardcoded font sizes with semantic Dynamic Type fonts - Fix FIXME in IconPickerView (log icon change errors) - Extract magic animation delays to named constants across 8 files - Add widget empty state "Log your first mood!" messaging - Hide decorative images from VoiceOver, add labels to ColorPickers - Remove stale TODO in Color+Codable (alpha change deferred for migration) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
437 lines
13 KiB
Swift
437 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)
|
|
.accessibilityHidden(true)
|
|
Text("\(count)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel("\(count) \(mood.strValue)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"))
|
|
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
|
|
}
|
|
}
|
|
|
|
// 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: ""
|
|
)
|
|
}
|