Fix widget layout clipping and add comprehensive widget previews

- Fix LargeVotingView mood icons getting clipped at edges by using
  flexible HStack spacing with maxWidth: .infinity
- Fix VotingView medium layout with smaller icons and even distribution
- Add comprehensive #Preview macros for all widget states:
  - Vote widget: small/medium, voted/not voted, all mood states
  - Timeline widget: small/medium/large with various data states
- Reduce icon sizes and padding to fit within widget bounds
- Update accessibility labels and hints across views

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2025-12-24 09:53:40 -06:00
parent 5f7d909d62
commit be84825aba
33 changed files with 10467 additions and 9725 deletions

View File

@@ -138,13 +138,15 @@ class WatchTimelineView: Identifiable {
let date: Date
let color: Color
let secondaryColor: Color
init(image: Image, graphic: Image, date: Date, color: Color, secondaryColor: Color) {
let mood: Mood
init(image: Image, graphic: Image, date: Date, color: Color, secondaryColor: Color, mood: Mood) {
self.image = image
self.date = date
self.color = color
self.color = color
self.graphic = graphic
self.secondaryColor = secondaryColor
self.mood = mood
}
}
@@ -171,13 +173,15 @@ struct TimeLineCreator {
graphic: moodImages.icon(forMood: todayEntry.mood),
date: dayStart,
color: moodTint.color(forMood: todayEntry.mood),
secondaryColor: moodTint.secondary(forMood: todayEntry.mood)))
secondaryColor: moodTint.secondary(forMood: todayEntry.mood),
mood: todayEntry.mood))
} else {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: .missing),
graphic: moodImages.icon(forMood: .missing),
date: dayStart,
color: moodTint.color(forMood: .missing),
secondaryColor: moodTint.secondary(forMood: .missing)))
secondaryColor: moodTint.secondary(forMood: .missing),
mood: .missing))
}
}
@@ -202,7 +206,8 @@ struct TimeLineCreator {
graphic: moodImages.icon(forMood: mood),
date: dayStart,
color: moodTint.color(forMood: mood),
secondaryColor: moodTint.secondary(forMood: mood)
secondaryColor: moodTint.secondary(forMood: mood),
mood: mood
))
}
@@ -377,6 +382,7 @@ struct SmallWidgetView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 70, height: 70)
.foregroundColor(today.color)
.accessibilityLabel(today.mood.strValue)
Spacer()
.frame(height: 12)
@@ -470,7 +476,8 @@ struct MediumWidgetView: View {
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight
height: cellHeight,
mood: item.mood
)
}
}
@@ -491,6 +498,7 @@ struct MediumDayCell: View {
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
ZStack {
@@ -500,7 +508,7 @@ struct MediumDayCell: View {
VStack(spacing: 4) {
Text(dayLabel)
.font(.system(size: 10, weight: isToday ? .bold : .medium))
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
@@ -509,9 +517,10 @@ struct MediumDayCell: View {
.aspectRatio(contentMode: .fit)
.frame(width: 36, height: 36)
.foregroundColor(color)
.accessibilityLabel(mood.strValue)
Text(dateLabel)
.font(.system(size: 13, weight: isToday ? .bold : .semibold))
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
@@ -584,7 +593,8 @@ struct LargeWidgetView: View {
image: item.image,
color: item.color,
isToday: index == 0,
height: cellHeight
height: cellHeight,
mood: item.mood
)
}
}
@@ -598,7 +608,8 @@ struct LargeWidgetView: View {
image: item.image,
color: item.color,
isToday: false,
height: cellHeight
height: cellHeight,
mood: item.mood
)
}
}
@@ -634,11 +645,12 @@ struct DayCell: View {
let color: Color
let isToday: Bool
let height: CGFloat
let mood: Mood
var body: some View {
VStack(spacing: 2) {
Text(dayLabel)
.font(.system(size: 10, weight: isToday ? .bold : .medium))
.font(.caption2.weight(isToday ? .bold : .medium))
.foregroundStyle(isToday ? .primary : .secondary)
.textCase(.uppercase)
@@ -653,9 +665,10 @@ struct DayCell: View {
.aspectRatio(contentMode: .fit)
.frame(width: 38, height: 38)
.foregroundColor(color)
.accessibilityLabel(mood.strValue)
Text(dateLabel)
.font(.system(size: 13, weight: isToday ? .bold : .semibold))
.font(.caption.weight(isToday ? .bold : .semibold))
.foregroundStyle(isToday ? color : .secondary)
}
}
@@ -679,26 +692,29 @@ struct LargeVotingView: View {
}
var body: some View {
VStack(spacing: 24) {
VStack(spacing: 16) {
Spacer()
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title2.weight(.semibold))
.font(.title3.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.horizontal, 8)
// Large mood buttons in a row
HStack(spacing: 20) {
// Large mood buttons in a row - flexible spacing
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood)
.frame(maxWidth: .infinity)
}
}
Spacer()
}
.padding()
.padding(.horizontal, 12)
.padding(.vertical, 16)
}
@ViewBuilder
@@ -708,29 +724,35 @@ struct LargeVotingView: View {
moodButtonContent(for: mood)
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodButtonContent(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
}
}
private func moodButtonContent(for mood: Mood) -> some View {
VStack(spacing: 8) {
VStack(spacing: 4) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.frame(width: 40, height: 40)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.strValue)
.font(.caption.weight(.medium))
Text(mood.widgetDisplayName)
.font(.caption2.weight(.medium))
.foregroundColor(moodTint.color(forMood: mood))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.padding(.vertical, 12)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.padding(.horizontal, 4)
.background(
RoundedRectangle(cornerRadius: 16)
RoundedRectangle(cornerRadius: 12)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
}
@@ -899,10 +921,14 @@ struct InlineVotingView: View {
moodIcon(for: mood)
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodIcon(for: mood)
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
}
}
@@ -917,13 +943,14 @@ struct InlineVotingView: View {
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)
}
}
@@ -1011,115 +1038,364 @@ struct FeelsGraphicWidget: Widget {
// MARK: - Preview Helpers
private extension FeelsWidget_Previews {
static func sampleTimelineViews(count: Int) -> [WatchTimelineView] {
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[index % moods.count]
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)
secondaryColor: MoodTints.Default.secondary(forMood: mood),
mood: mood
)
}
}
static func sampleEntry(timelineCount: Int = 5) -> SimpleEntry {
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),
hasSubscription: true,
hasVotedToday: true
timeLineViews: sampleTimelineViews(count: timelineCount, startMood: startMood),
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
promptText: "How are you feeling today?"
)
}
}
struct FeelsWidget_Previews: PreviewProvider {
static var previews: some View {
Group {
// MARK: - FeelsWidget (Timeline)
FeelsWidgetEntryView(entry: sampleEntry(timelineCount: 1))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Timeline - Small")
// MARK: - FeelsWidget Previews (Timeline Widget)
FeelsWidgetEntryView(entry: sampleEntry(timelineCount: 5))
.previewContext(WidgetPreviewContext(family: .systemMedium))
.previewDisplayName("Timeline - Medium")
FeelsWidgetEntryView(entry: sampleEntry(timelineCount: 10))
.previewContext(WidgetPreviewContext(family: .systemLarge))
.previewDisplayName("Timeline - Large")
// MARK: - FeelsGraphicWidget (Mood Graphic)
FeelsGraphicWidgetEntryView(entry: sampleEntry(timelineCount: 2))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Mood Graphic - Small")
// MARK: - FeelsIconWidget (Custom Icon)
FeelsIconWidgetEntryView(entry: sampleEntry())
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Custom Icon - Small")
// MARK: - FeelsVoteWidget (Vote - Not Voted)
FeelsVoteWidgetEntryView(entry: VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling?"
))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Vote - Small (Not Voted)")
FeelsVoteWidgetEntryView(entry: VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling?"
))
.previewContext(WidgetPreviewContext(family: .systemMedium))
.previewDisplayName("Vote - Medium (Not Voted)")
// MARK: - FeelsVoteWidget (Vote - Already Voted)
FeelsVoteWidgetEntryView(entry: VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]),
promptText: ""
))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Vote - Small (Voted)")
FeelsVoteWidgetEntryView(entry: VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: MoodStats(totalEntries: 45, moodCounts: [.great: 15, .good: 18, .average: 8, .bad: 3, .horrible: 1]),
promptText: ""
))
.previewContext(WidgetPreviewContext(family: .systemMedium))
.previewDisplayName("Vote - Medium (Voted)")
// MARK: - FeelsVoteWidget (Non-Subscriber)
FeelsVoteWidgetEntryView(entry: VoteWidgetEntry(
date: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
))
.previewContext(WidgetPreviewContext(family: .systemSmall))
.previewDisplayName("Vote - Small (Non-Subscriber)")
}
}
// Small - Logged States
#Preview("Timeline Small - Great", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .great)
}
#Preview("Timeline Small - Good", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .good)
}
#Preview("Timeline Small - Average", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .average)
}
#Preview("Timeline Small - Bad", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .bad)
}
#Preview("Timeline Small - Horrible", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, startMood: .horrible)
}
// Small - Voting States
#Preview("Timeline Small - Voting", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false)
}
#Preview("Timeline Small - Non-Subscriber", as: .systemSmall) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 1, hasVotedToday: false, hasSubscription: false)
}
// Medium - Logged States
#Preview("Timeline Medium - Logged", as: .systemMedium) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5)
}
// Medium - Voting States
#Preview("Timeline Medium - Voting", as: .systemMedium) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false)
}
#Preview("Timeline Medium - Non-Subscriber", as: .systemMedium) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 5, hasVotedToday: false, hasSubscription: false)
}
// Large - Logged States
#Preview("Timeline Large - Logged", as: .systemLarge) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10)
}
// Large - Voting States
#Preview("Timeline Large - Voting", as: .systemLarge) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false)
}
#Preview("Timeline Large - Non-Subscriber", as: .systemLarge) {
FeelsWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 10, hasVotedToday: false, hasSubscription: false)
}
// MARK: - FeelsGraphicWidget Previews (Mood Graphic)
#Preview("Graphic - Great", as: .systemSmall) {
FeelsGraphicWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 2, startMood: .great)
}
#Preview("Graphic - Good", as: .systemSmall) {
FeelsGraphicWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 2, startMood: .good)
}
#Preview("Graphic - Average", as: .systemSmall) {
FeelsGraphicWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 2, startMood: .average)
}
#Preview("Graphic - Bad", as: .systemSmall) {
FeelsGraphicWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 2, startMood: .bad)
}
#Preview("Graphic - Horrible", as: .systemSmall) {
FeelsGraphicWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry(timelineCount: 2, startMood: .horrible)
}
// MARK: - FeelsIconWidget Previews (Custom Icon)
#Preview("Custom Icon", as: .systemSmall) {
FeelsIconWidget()
} timeline: {
WidgetPreviewHelpers.sampleEntry()
}
// MARK: - Live Activity Previews (Lock Screen View)
#Preview("Live Activity - Not Logged") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("7")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
VStack(alignment: .leading) {
Text("Don't break your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}
#Preview("Live Activity - Great") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("15")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
HStack(spacing: 8) {
Circle()
.fill(MoodTints.Default.color(forMood: .great))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text("Great")
.font(.headline)
}
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}
#Preview("Live Activity - Good") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("30")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
HStack(spacing: 8) {
Circle()
.fill(MoodTints.Default.color(forMood: .good))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text("Good")
.font(.headline)
}
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}
#Preview("Live Activity - Average") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("10")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
HStack(spacing: 8) {
Circle()
.fill(MoodTints.Default.color(forMood: .average))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text("Average")
.font(.headline)
}
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}
#Preview("Live Activity - Bad") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("5")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
HStack(spacing: 8) {
Circle()
.fill(MoodTints.Default.color(forMood: .bad))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text("Bad")
.font(.headline)
}
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}
#Preview("Live Activity - Horrible") {
HStack(spacing: 16) {
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("3")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
HStack(spacing: 8) {
Circle()
.fill(MoodTints.Default.color(forMood: .horrible))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text("Horrible")
.font(.headline)
}
}
Spacer()
}
.padding()
.background(Color(.systemBackground).opacity(0.8))
}