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:
@@ -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: ""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user