Files
Reflect/ReflectWidget/ReflectTimelineWidget.swift
Trey T 1f040ab676 v1.1 polish: accessibility, error logging, localization, and code quality sweep
- 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>
2026-03-26 20:09:14 -05:00

603 lines
20 KiB
Swift

//
// ReflectTimelineWidget.swift
// ReflectWidget
//
// Timeline widget showing mood history (small, medium, large)
//
import WidgetKit
import SwiftUI
import Intents
// MARK: - Widget Configuration
struct ReflectWidget: Widget {
let kind: String = "ReflectWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
ReflectWidgetEntryView(entry: entry)
}
.configurationDisplayName("Reflect")
.description("")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
// MARK: - Entry View Router
struct ReflectWidgetEntryView: View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
@ViewBuilder
var body: some View {
Group {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
case .systemExtraLarge:
LargeWidgetView(entry: entry)
case .accessoryCircular, .accessoryRectangular, .accessoryInline:
SmallWidgetView(entry: entry)
@unknown default:
MediumWidgetView(entry: entry)
}
}
.containerBackground(showVotingForToday ? Color.clear : Color(UIColor.systemBackground), for: .widget)
}
}
// MARK: - Small Widget View
struct SmallWidgetView: View {
var entry: Provider.Entry
var todayView: WatchTimelineView?
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "MMM d"
return f
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = TimeLineCreator.createViews(daysBack: 2)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
}
var body: some View {
if showVotingForToday {
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else if let today = todayView {
VStack(spacing: 0) {
if isSampleData {
Text(String(localized: "Log your first mood!"))
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.top, 8)
}
Spacer()
// Large mood icon
today.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 70, height: 70)
.foregroundColor(today.color)
.accessibilityLabel(today.mood.strValue)
Spacer()
.frame(height: 12)
// Date info
VStack(spacing: 2) {
Text(dayFormatter.string(from: today.date))
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.textCase(.uppercase)
Text(dateFormatter.string(from: today.date))
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
}
Spacer()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// MARK: - Medium Widget View
struct MediumWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "d"
return f
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
}
private var headerDateRange: String {
guard let first = timeLineView.first, let last = timeLineView.last else { return "" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))"
}
var body: some View {
if showVotingForToday {
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemMedium, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
VStack(spacing: 4) {
// Header
HStack {
Text("Last 5 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
if isSampleData {
Text("·")
.foregroundStyle(.secondary)
Text(String(localized: "Log your first mood!"))
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("·")
.foregroundStyle(.secondary)
Text(headerDateRange)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 14)
.padding(.top, 10)
// Single row of 5 days
HStack(spacing: 8) {
ForEach(Array(timeLineView.enumerated()), id: \.element.id) { index, item in
MediumDayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
.padding(.horizontal, 10)
.padding(.bottom, 10)
}
}
}
}
}
// MARK: - Medium Day Cell
struct MediumDayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(isToday ? 0.25 : 0.12))
.frame(height: height)
VStack(spacing: 4) {
Text(dayLabel)
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.foregroundColor(color)
.accessibilityLabel(mood.strValue)
Text(dateLabel)
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Large Widget View
struct LargeWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
private var isSampleData: Bool
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
isSampleData = !hasRealData
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
}
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "d"
return f
}
var body: some View {
if showVotingForToday {
// Show interactive voting buttons for large widget (or open app links if expired)
LargeVotingView(promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows
VStack(spacing: 6) {
// Header
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Last 10 Days")
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(isSampleData ? String(localized: "Log your first mood!") : headerDateRange)
.font(.caption2)
.foregroundStyle(.secondary)
}
Spacer()
}
.padding(.horizontal, 12)
.padding(.top, 8)
// Calendar grid - 2 rows of 5
VStack(spacing: 6) {
// First row (most recent 5)
HStack(spacing: 6) {
ForEach(Array(timeLineView.prefix(5).enumerated()), id: \.element.id) { index, item in
DayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight,
mood: item.mood
)
}
}
// Second row (older 5)
HStack(spacing: 6) {
ForEach(Array(timeLineView.suffix(5).enumerated()), id: \.element.id) { _, item in
DayCell(
dayLabel: dayFormatter.string(from: item.date),
dateLabel: dateFormatter.string(from: item.date),
image: item.image,
color: item.color,
isToday: false,
height: cellHeight,
mood: item.mood
)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
}
private var headerDateRange: String {
guard let first = timeLineView.first, let last = timeLineView.last else { return "" }
let formatter = DateFormatter()
formatter.dateFormat = "MMM d"
return "\(formatter.string(from: last.date)) - \(formatter.string(from: first.date))"
}
}
// MARK: - Day Cell for Large Widget
struct DayCell: View {
let dayLabel: String
let dateLabel: String
let image: Image
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
VStack(spacing: 2) {
Text(dayLabel)
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
ZStack {
RoundedRectangle(cornerRadius: 14)
.fill(color.opacity(isToday ? 0.25 : 0.12))
.frame(height: height - 16)
VStack(spacing: 6) {
image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 38, height: 38)
.foregroundColor(color)
.accessibilityLabel(mood.strValue)
Text(dateLabel)
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
}
.frame(maxWidth: .infinity)
}
}
// MARK: - Supporting Views
struct TimeHeaderView: View {
let startDate: Date
let endDate: Date
var formatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter
}
var body: some View {
HStack {
Text(startDate, formatter: formatter)
.font(.system(.footnote))
Text(" - ")
.font(.system(.footnote))
Text(endDate, formatter: formatter)
.font(.system(.footnote))
}
}
}
struct TimeBodyView: View {
let group: [WatchTimelineView]
var showVotingForToday: Bool = false
var promptText: String = ""
var hasSubscription: Bool = false
var body: some View {
if showVotingForToday {
// Show voting view without extra background container
InlineVotingView(promptText: promptText, hasSubscription: hasSubscription)
.padding()
} else {
ZStack {
Color(UIColor.secondarySystemBackground)
HStack(spacing: 4) {
ForEach(group) { watchView in
EntryCard(timeLineView: watchView)
}
}
.padding()
}
}
}
}
struct EntryCard: View {
var timeLineView: WatchTimelineView
var body: some View {
timeLineView.image
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 50, height: 50, alignment: .center)
.foregroundColor(timeLineView.color)
.accessibilityLabel(timeLineView.mood.strValue)
}
}
// MARK: - Preview Helpers
private enum WidgetPreviewHelpers {
static func sampleTimelineViews(count: Int, startMood: Mood = .great) -> [WatchTimelineView] {
let moods: [Mood] = [.great, .good, .average, .bad, .horrible]
let startIndex = moods.firstIndex(of: startMood) ?? 0
return (0..<count).map { index in
let mood = moods[(startIndex + index) % moods.count]
return WatchTimelineView(
image: EmojiMoodImages.icon(forMood: mood),
graphic: EmojiMoodImages.icon(forMood: mood),
date: Calendar.current.date(byAdding: .day, value: -index, to: Date())!,
color: MoodTints.Default.color(forMood: mood),
secondaryColor: MoodTints.Default.secondary(forMood: mood),
mood: mood
)
}
}
static func sampleEntry(timelineCount: Int = 5, hasVotedToday: Bool = true, hasSubscription: Bool = true, startMood: Mood = .great) -> SimpleEntry {
SimpleEntry(
date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: sampleTimelineViews(count: timelineCount, startMood: startMood),
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
promptText: "How are you feeling today?"
)
}
}
// MARK: - Previews
// Small - Logged States
#Preview("Timeline Small - Great", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .great)
}
#Preview("Timeline Small - Good", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .good)
}
#Preview("Timeline Small - Average", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .average)
}
#Preview("Timeline Small - Bad", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .bad)
}
#Preview("Timeline Small - Horrible", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .horrible)
}
// Small - Voting States
#Preview("Timeline Small - Voting", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false)
}
#Preview("Timeline Small - Non-Subscriber", as: .systemSmall) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false, hasSubscription: false)
}
// Medium - Logged States
#Preview("Timeline Medium - Logged", as: .systemMedium) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5)
}
// Medium - Voting States
#Preview("Timeline Medium - Voting", as: .systemMedium) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false)
}
#Preview("Timeline Medium - Non-Subscriber", as: .systemMedium) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false, hasSubscription: false)
}
// Large - Logged States
#Preview("Timeline Large - Logged", as: .systemLarge) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10)
}
// Large - Voting States
#Preview("Timeline Large - Voting", as: .systemLarge) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false)
}
#Preview("Timeline Large - Non-Subscriber", as: .systemLarge) {
ReflectWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false, hasSubscription: false)
}