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

@@ -227,31 +227,31 @@ struct VotingView: View {
// MARK: - Medium Widget: Single row
private var mediumLayout: some View {
VStack {
VStack(spacing: 12) {
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.headline)
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
.padding(.bottom, 20)
HStack(spacing: 16) {
HStack(spacing: 0) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood, size: 44)
moodButtonMedium(for: mood)
.frame(maxWidth: .infinity)
}
}
}
.padding()
.padding(.horizontal, 12)
.padding(.vertical, 16)
}
@ViewBuilder
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
// Ensure minimum 44x44 touch target for accessibility
// Used for small widget
let touchSize = max(size, 44)
if hasSubscription {
// Active subscription: vote normally
Button(intent: VoteMoodIntent(mood: mood)) {
moodIcon(for: mood, size: size)
.frame(minWidth: touchSize, minHeight: touchSize)
@@ -260,7 +260,6 @@ struct VotingView: View {
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
} else {
// Trial expired: open app to subscribe
Link(destination: URL(string: "feels://subscribe")!) {
moodIcon(for: mood, size: size)
.frame(minWidth: touchSize, minHeight: touchSize)
@@ -270,6 +269,39 @@ struct VotingView: View {
}
}
@ViewBuilder
private func moodButtonMedium(for mood: Mood) -> some View {
// Medium widget uses smaller icons with labels, flexible width
let content = VStack(spacing: 4) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.widgetDisplayName)
.font(.caption2)
.foregroundColor(moodTint.color(forMood: mood))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
content
}
.buttonStyle(.plain)
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Log this mood"))
} else {
Link(destination: URL(string: "feels://subscribe")!) {
content
}
.accessibilityLabel(mood.strValue)
.accessibilityHint(String(localized: "Open app to subscribe"))
}
}
private func moodIcon(for mood: Mood, size: CGFloat) -> some View {
moodImages.icon(forMood: mood)
.resizable()
@@ -315,11 +347,13 @@ struct VotedStatsView: View {
// Checkmark badge
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 18))
.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("Logged!")
.font(.caption.weight(.semibold))
@@ -331,8 +365,6 @@ struct VotedStatsView: View {
.foregroundStyle(.tertiary)
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(String(localized: "Mood logged: \(entry.todaysMood?.strValue ?? "")"))
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding(12)
@@ -340,7 +372,7 @@ struct VotedStatsView: View {
// MARK: - Medium: Mood + stats bar
private var mediumLayout: some View {
HStack(spacing: 20) {
HStack(alignment: .top, spacing: 20) {
if let mood = entry.todaysMood {
// Left: Mood display
VStack(spacing: 6) {
@@ -349,6 +381,7 @@ struct VotedStatsView: View {
.aspectRatio(contentMode: .fit)
.frame(width: 48, height: 48)
.foregroundColor(moodTint.color(forMood: mood))
.accessibilityLabel(mood.strValue)
Text(mood.widgetDisplayName)
.font(.subheadline.weight(.semibold))
@@ -359,11 +392,11 @@ struct VotedStatsView: View {
.foregroundStyle(.secondary)
}
// Right: Stats
// Right: Stats with progress bar aligned under title
if let stats = entry.stats {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 10) {
Text("\(stats.totalEntries) entries")
.font(.caption.weight(.medium))
.font(.headline.weight(.semibold))
.foregroundStyle(.primary)
// Mini mood breakdown
@@ -383,21 +416,21 @@ struct VotedStatsView: View {
}
}
// Progress bar
// Progress bar - aligned with title
GeometryReader { geo in
HStack(spacing: 1) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
let percentage = stats.percentage(for: mood)
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: mood))
.fill(moodTint.color(forMood: m))
.frame(width: max(4, geo.size.width * CGFloat(percentage) / 100))
}
}
}
}
.frame(height: 8)
.clipShape(RoundedRectangle(cornerRadius: 4))
.frame(height: 10)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -449,12 +482,202 @@ struct FeelsVoteWidget: Widget {
}
}
// MARK: - Preview
// MARK: - Preview Helpers
#Preview(as: .systemSmall) {
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) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?")
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: "")
VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "")
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling today?"
)
}
#Preview("Vote Small - Voted Great", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Good", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Average", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Bad", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Voted Horrible", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Small - Non-Subscriber", as: .systemSmall) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
)
}
// MARK: - Medium Widget Previews
#Preview("Vote Medium - Not Voted", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: "How are you feeling today?"
)
}
#Preview("Vote Medium - Voted Great", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .great,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Good", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .good,
stats: VoteWidgetPreviewHelpers.largeStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Average", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .average,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Bad", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .bad,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Voted Horrible", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: true,
hasVotedToday: true,
todaysMood: .horrible,
stats: VoteWidgetPreviewHelpers.sampleStats,
promptText: ""
)
}
#Preview("Vote Medium - Non-Subscriber", as: .systemMedium) {
FeelsVoteWidget()
} timeline: {
VoteWidgetEntry(
date: Date(),
hasSubscription: false,
hasVotedToday: false,
todaysMood: nil,
stats: nil,
promptText: ""
)
}