Fix widget layouts and remove borders on graphic widgets
- Redesign SmallWidgetView with centered mood icon and date info - Redesign MediumWidgetView with calendar-style day cells - Redesign LargeWidgetView with 5x2 calendar grid layout - Fix FeelsGraphicWidget border by using containerBackground properly - Fix FeelsIconWidget border with contentMarginsDisabled - Add DayCell and MediumDayCell components for consistent styling 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -335,34 +335,61 @@ struct FeelsWidgetEntryView : View {
|
||||
|
||||
struct SmallWidgetView: View {
|
||||
var entry: Provider.Entry
|
||||
var timeLineView = [WatchTimelineView]()
|
||||
var todayView: WatchTimelineView?
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = TimeLineCreator.createViews(daysBack: 2)
|
||||
// Check if we have any real mood data (not all missing)
|
||||
let hasRealData = realData.contains { view in
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
}
|
||||
if let firstView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first {
|
||||
timeLineView = [firstView]
|
||||
}
|
||||
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Color(UIColor.secondarySystemBackground)
|
||||
HStack {
|
||||
ForEach(self.timeLineView) { watchView in
|
||||
EntryCard(timeLineView: watchView)
|
||||
if let today = todayView {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
// Large mood icon
|
||||
today.image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 70, height: 70)
|
||||
.foregroundColor(today.color)
|
||||
|
||||
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()
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
|
||||
.frame(minHeight: 0, maxHeight: 55)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,10 +401,21 @@ struct MediumWidgetView: View {
|
||||
entry.hasSubscription && !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
|
||||
}
|
||||
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
|
||||
// Check if we have any real mood data (not all missing)
|
||||
let hasRealData = realData.contains { view in
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
@@ -385,26 +423,90 @@ struct MediumWidgetView: View {
|
||||
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 {
|
||||
VStack {
|
||||
Spacer()
|
||||
GeometryReader { geo in
|
||||
let cellHeight = geo.size.height - 36
|
||||
|
||||
if !showVotingForToday, let first = timeLineView.first, let last = timeLineView.last {
|
||||
TimeHeaderView(startDate: first.date, endDate: last.date)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
VStack(spacing: 4) {
|
||||
// Header
|
||||
HStack {
|
||||
Text("Last 5 Days")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
TimeBodyView(group: timeLineView, showVotingForToday: showVotingForToday, promptText: entry.promptText)
|
||||
.clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous))
|
||||
.frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Cell for Medium Widget
|
||||
|
||||
struct MediumDayCell: View {
|
||||
let dayLabel: String
|
||||
let dateLabel: String
|
||||
let image: Image
|
||||
let color: Color
|
||||
let isToday: Bool
|
||||
let height: CGFloat
|
||||
|
||||
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(.system(size: 10, weight: isToday ? .bold : .medium))
|
||||
.foregroundStyle(isToday ? .primary : .secondary)
|
||||
.textCase(.uppercase)
|
||||
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 36, height: 36)
|
||||
.foregroundColor(color)
|
||||
|
||||
Text(dateLabel)
|
||||
.font(.system(size: 13, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? color : .secondary)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct LargeWidgetView: View {
|
||||
var entry: Provider.Entry
|
||||
var timeLineView = [WatchTimelineView]()
|
||||
@@ -416,7 +518,6 @@ struct LargeWidgetView: View {
|
||||
init(entry: Provider.Entry) {
|
||||
self.entry = entry
|
||||
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
||||
// Check if we have any real mood data (not all missing)
|
||||
let hasRealData = realData.contains { view in
|
||||
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
||||
return view.color != moodTint.color(forMood: .missing)
|
||||
@@ -424,57 +525,126 @@ struct LargeWidgetView: View {
|
||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
|
||||
}
|
||||
|
||||
var firstGroup: [WatchTimelineView] {
|
||||
return Array(self.timeLineView.prefix(5))
|
||||
private var dayFormatter: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEE"
|
||||
return f
|
||||
}
|
||||
|
||||
var secondGroup: [WatchTimelineView] {
|
||||
return Array(self.timeLineView.suffix(5))
|
||||
private var dateFormatter: DateFormatter {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "d"
|
||||
return f
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
GeometryReader { geo in
|
||||
let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows
|
||||
|
||||
// First row (includes today - may show voting)
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
if !showVotingForToday, let first = firstGroup.first, let last = firstGroup.last {
|
||||
TimeHeaderView(startDate: first.date, endDate: last.date)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
VStack(spacing: 6) {
|
||||
// Header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Last 10 Days")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(headerDateRange)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
|
||||
TimeBodyView(group: firstGroup, showVotingForToday: showVotingForToday, promptText: entry.promptText)
|
||||
.clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous))
|
||||
.frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55)
|
||||
.padding()
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Second row (older entries - never show voting)
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
if let first = secondGroup.first, let last = secondGroup.last {
|
||||
TimeHeaderView(startDate: first.date, endDate: last.date)
|
||||
.frame(minWidth: 0, maxWidth: .infinity)
|
||||
.multilineTextAlignment(.leading)
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TimeBodyView(group: secondGroup, showVotingForToday: false)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
|
||||
.frame(minHeight: 0, maxHeight: 55)
|
||||
.padding()
|
||||
|
||||
Spacer()
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
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))"
|
||||
}
|
||||
|
||||
private var dominantMood: (image: Image, color: Color)? {
|
||||
guard !timeLineView.isEmpty else { return nil }
|
||||
// Return the most recent mood
|
||||
let first = timeLineView.first!
|
||||
return (first.image, first.color)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 2) {
|
||||
Text(dayLabel)
|
||||
.font(.system(size: 10, 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)
|
||||
|
||||
Text(dateLabel)
|
||||
.font(.system(size: 13, weight: isToday ? .bold : .semibold))
|
||||
.foregroundStyle(isToday ? color : .secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
/**********************************************************/
|
||||
struct FeelsGraphicWidgetEntryView : View {
|
||||
@@ -486,7 +656,6 @@ struct FeelsGraphicWidgetEntryView : View {
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
SmallGraphicWidgetView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -505,17 +674,24 @@ struct SmallGraphicWidgetView: View {
|
||||
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 2)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
private var iconViewModel: IconViewModel {
|
||||
if let first = timeLineView.first {
|
||||
IconView(iconViewModel: IconViewModel(backgroundImage: first.graphic,
|
||||
bgColor: first.color,
|
||||
bgOverlayColor: first.secondaryColor,
|
||||
centerImage: first.graphic,
|
||||
innerColor: first.color))
|
||||
return IconViewModel(backgroundImage: first.graphic,
|
||||
bgColor: first.color,
|
||||
bgOverlayColor: first.secondaryColor,
|
||||
centerImage: first.graphic,
|
||||
innerColor: first.color)
|
||||
} else {
|
||||
IconView(iconViewModel: IconViewModel.great)
|
||||
return IconViewModel.great
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Color.clear
|
||||
.containerBackground(for: .widget) {
|
||||
IconView(iconViewModel: iconViewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
/**********************************************************/
|
||||
struct FeelsIconWidgetEntryView : View {
|
||||
@@ -527,25 +703,23 @@ struct FeelsIconWidgetEntryView : View {
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
SmallIconView(entry: entry)
|
||||
.containerBackground(.fill.tertiary, for: .widget)
|
||||
}
|
||||
}
|
||||
|
||||
struct SmallIconView: View {
|
||||
var entry: Provider.Entry
|
||||
|
||||
|
||||
private var customWidget: CustomWidgetModel {
|
||||
UserDefaultsStore.getCustomWidgets().first(where: { $0.inUse == true })
|
||||
?? CustomWidgetModel.randomWidget
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
if let inUseWidget = UserDefaultsStore.getCustomWidgets().first(where: {
|
||||
$0.inUse == true
|
||||
}) {
|
||||
CustomWidgetView(customWidgetModel: inUseWidget)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} else {
|
||||
CustomWidgetView(customWidgetModel: CustomWidgetModel.randomWidget)
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
CustomWidgetView(customWidgetModel: customWidget)
|
||||
.ignoresSafeArea()
|
||||
.containerBackground(for: .widget) {
|
||||
customWidget.bgColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/**********************************************************/
|
||||
@@ -698,7 +872,7 @@ struct FeelsWidget: Widget {
|
||||
|
||||
struct FeelsIconWidget: Widget {
|
||||
let kind: String = "FeelsIconWidget"
|
||||
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
IntentConfiguration(kind: kind,
|
||||
intent: ConfigurationIntent.self,
|
||||
@@ -708,12 +882,13 @@ struct FeelsIconWidget: Widget {
|
||||
.configurationDisplayName("Feels Icon")
|
||||
.description("")
|
||||
.supportedFamilies([.systemSmall])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
struct FeelsGraphicWidget: Widget {
|
||||
let kind: String = "FeelsGraphicWidget"
|
||||
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
IntentConfiguration(kind: kind,
|
||||
intent: ConfigurationIntent.self,
|
||||
@@ -723,6 +898,7 @@ struct FeelsGraphicWidget: Widget {
|
||||
.configurationDisplayName("Mood Graphic")
|
||||
.description("")
|
||||
.supportedFamilies([.systemSmall])
|
||||
.contentMarginsDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user