Files
Reflect/ReflectWidget/WidgetSharedViews.swift
Trey T ed8205cd88 Complete accessibility identifier coverage across all 152 project files
Exhaustive file-by-file audit of every Swift file in the project (iOS app,
Watch app, Widget extension). Every interactive UI element — buttons, toggles,
pickers, links, menus, tap gestures, text editors, color pickers, photo
pickers — now has an accessibilityIdentifier for XCUITest automation.

46 files changed across Shared/, Onboarding/, Watch App/, and Widget targets.
Added ~100 new ID definitions covering settings debug controls, export/photo
views, sharing templates, customization subviews, onboarding flows, tip
modals, widget voting buttons, and watch mood buttons.
2026-03-26 08:34:56 -05:00

289 lines
9.9 KiB
Swift

//
// WidgetSharedViews.swift
// ReflectWidget
//
// Shared voting views used across multiple widgets
//
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - Voting View
struct VotingView: View {
let family: WidgetFamily
let promptText: String
let hasSubscription: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
var body: some View {
if family == .systemSmall {
smallLayout
} else {
mediumLayout
}
}
// MARK: - Small Widget: 3 over 2 grid centered in 50%|50% vertical split
private var smallLayout: some View {
VStack(spacing: 0) {
// Top half: Great, Good, Average
HStack(spacing: 12) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodButton(for: mood, size: 40)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
// Bottom half: Bad, Horrible
HStack(spacing: 12) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood, size: 40)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
}
}
// MARK: - Medium Widget: 50/50 split, both centered
private var mediumLayout: some View {
VStack(spacing: 0) {
// Top 50%: Text left-aligned, vertically centered
HStack {
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.system(size: 20, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.lineLimit(2)
.minimumScaleFactor(0.8)
Spacer()
}
.padding(.horizontal, 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom 50%: Voting buttons centered
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButtonMedium(for: mood)
.frame(maxWidth: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@ViewBuilder
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
// Used for small widget
let touchSize = max(size, 44)
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodIcon(for: mood, size: size)
.frame(minWidth: touchSize, minHeight: touchSize)
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood, size: size)
.frame(minWidth: touchSize, minHeight: touchSize)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
@ViewBuilder
private func moodButtonMedium(for mood: Mood) -> some View {
// Medium widget uses icons only (accessibility labels provide screen reader support)
let content = moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 54, height: 54)
.foregroundColor(moodTint.color(forMood: mood))
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
content
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
content
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(moodTint.color(forMood: mood))
}
}
// MARK: - Large Voting View
struct LargeVotingView: View {
let promptText: String
let hasSubscription: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
var body: some View {
GeometryReader { geo in
VStack(spacing: 0) {
// Top 33%: Title centered
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 12)
.frame(width: geo.size.width, height: geo.size.height * 0.33)
// Bottom 66%: Voting buttons in two rows
VStack(spacing: 0) {
// Top row: Great, Good, Average
HStack(spacing: 16) {
ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in
moodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
// Bottom row: Bad, Horrible
HStack(spacing: 16) {
ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geo.size.width, height: geo.size.height * 0.67)
}
}
}
@ViewBuilder
private func moodButton(for mood: Mood) -> some View {
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodButtonContent(for: mood)
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodButtonContent(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
private func moodButtonContent(for mood: Mood) -> some View {
// Large widget uses icons only (accessibility labels provide screen reader support)
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 53, height: 53)
.foregroundColor(moodTint.color(forMood: mood))
.padding(12)
.background(
RoundedRectangle(cornerRadius: 14)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
}
}
// MARK: - Inline Voting View (compact mood buttons for timeline widget)
struct InlineVotingView: View {
let promptText: String
let hasSubscription: Bool
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
var body: some View {
VStack(spacing: 8) {
Text(hasSubscription ? promptText : "Tap to open app")
.font(.subheadline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.7)
HStack(spacing: 8) {
ForEach(moods, id: \.rawValue) { mood in
moodButton(for: mood)
}
}
}
}
@ViewBuilder
private func moodButton(for mood: Mood) -> some View {
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodIcon(for: mood)
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
.accessibilityIdentifier(AccessibilityID.Widget.voteMoodButton(mood.strValue))
} else {
Link(destination: URL(string: "reflect://subscribe")!) {
moodIcon(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
.accessibilityIdentifier(AccessibilityID.Widget.subscribeLink)
}
}
private func moodIcon(for mood: Mood) -> some View {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 44, height: 44)
.foregroundColor(moodTint.color(forMood: mood))
}
}