Files
Reflect/FeelsWidget2/FeelsWidget.swift
Trey t 086f8b8807 Add comprehensive WCAG 2.1 AA accessibility support
- Add VoiceOver labels and hints to all voting layouts, settings, widgets,
  onboarding screens, and entry cells
- Add Reduce Motion support to button animations throughout the app
- Ensure 44x44pt minimum touch targets on widget mood buttons
- Enhance AccessibilityHelpers with Dynamic Type support, ScaledValue wrapper,
  and VoiceOver detection utilities
- Gate premium features (Insights, Month/Year views) behind subscription
- Update widgets to show subscription prompts for non-subscribers

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 23:26:21 -06:00

1126 lines
40 KiB
Swift

//
// FeelsWidget.swift
// FeelsWidget
//
// Created by Trey Tartt on 1/7/22.
//
import WidgetKit
import SwiftUI
import Intents
import SwiftData
import ActivityKit
import AppIntents
// MARK: - Live Activity Widget
// Note: MoodStreakAttributes is defined in MoodStreakActivity.swift (Shared folder)
struct MoodStreakLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: MoodStreakAttributes.self) { context in
// Lock Screen / StandBy view
MoodStreakLockScreenView(context: context)
} dynamicIsland: { context in
DynamicIsland {
// Expanded view
DynamicIslandExpandedRegion(.leading) {
HStack(spacing: 8) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
Text("\(context.state.currentStreak)")
.font(.title2.bold())
}
}
DynamicIslandExpandedRegion(.trailing) {
if context.state.hasLoggedToday {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title2)
} else {
Text("Log now")
.font(.caption)
.foregroundColor(.secondary)
}
}
DynamicIslandExpandedRegion(.center) {
Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : "Don't break your streak!")
.font(.headline)
}
DynamicIslandExpandedRegion(.bottom) {
if !context.state.hasLoggedToday {
Text("Voting closes at midnight")
.font(.caption)
.foregroundColor(.secondary)
} else {
HStack {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 20, height: 20)
Text("Today: \(context.state.lastMoodLogged)")
.font(.subheadline)
}
}
}
} compactLeading: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
} compactTrailing: {
Text("\(context.state.currentStreak)")
.font(.caption.bold())
} minimal: {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
}
}
}
}
struct MoodStreakLockScreenView: View {
let context: ActivityViewContext<MoodStreakAttributes>
var body: some View {
HStack(spacing: 16) {
// Streak indicator
VStack(spacing: 4) {
Image(systemName: "flame.fill")
.font(.title)
.foregroundColor(.orange)
Text("\(context.state.currentStreak)")
.font(.title.bold())
Text("day streak")
.font(.caption)
.foregroundColor(.secondary)
}
Divider()
.frame(height: 50)
// Status
VStack(alignment: .leading, spacing: 8) {
if context.state.hasLoggedToday {
HStack(spacing: 8) {
Circle()
.fill(Color(hex: context.state.lastMoodColor))
.frame(width: 24, height: 24)
VStack(alignment: .leading) {
Text("Today's mood")
.font(.caption)
.foregroundColor(.secondary)
Text(context.state.lastMoodLogged)
.font(.headline)
}
}
} else {
VStack(alignment: .leading) {
Text("Don't break your streak!")
.font(.headline)
Text("Tap to log your mood")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
Spacer()
}
.padding()
.activityBackgroundTint(Color(.systemBackground).opacity(0.8))
}
}
class WatchTimelineView: Identifiable {
let id = UUID()
let image: Image
let graphic: Image
let date: Date
let color: Color
let secondaryColor: Color
init(image: Image, graphic: Image, date: Date, color: Color, secondaryColor: Color) {
self.image = image
self.date = date
self.color = color
self.graphic = graphic
self.secondaryColor = secondaryColor
}
}
struct TimeLineCreator {
@MainActor static func createViews(daysBack: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let latestDayToShow = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dates = Array(0...daysBack).map({
Calendar.current.date(byAdding: .day, value: -$0, to: latestDayToShow)!
})
// Use WidgetDataProvider for isolated widget data access
let dataProvider = WidgetDataProvider.shared
for date in dates {
let dayStart = Calendar.current.startOfDay(for: date)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
if let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first {
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: todayEntry.mood),
graphic: moodImages.icon(forMood: todayEntry.mood),
date: dayStart,
color: moodTint.color(forMood: todayEntry.mood),
secondaryColor: moodTint.secondary(forMood: 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)))
}
}
timeLineView = timeLineView.sorted(by: { $0.date > $1.date })
return timeLineView
}
/// Creates sample preview data for widget picker - shows what widget looks like with mood data
static func createSampleViews(count: Int) -> [WatchTimelineView] {
var timeLineView = [WatchTimelineView]()
let sampleMoods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good, .average]
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
for i in 0..<count {
let date = Calendar.current.date(byAdding: .day, value: -i, to: Date())!
let dayStart = Calendar.current.startOfDay(for: date)
let mood = sampleMoods[i % sampleMoods.count]
timeLineView.append(WatchTimelineView(
image: moodImages.icon(forMood: mood),
graphic: moodImages.icon(forMood: mood),
date: dayStart,
color: moodTint.color(forMood: mood),
secondaryColor: moodTint.secondary(forMood: mood)
))
}
return timeLineView
}
}
struct Provider: @preconcurrency IntentTimelineProvider {
let timeLineCreator = TimeLineCreator()
/*
placeholder for widget, no data
gets redacted auto - uses sample data for widget picker preview
*/
func placeholder(in context: Context) -> SimpleEntry {
return SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: TimeLineCreator.createSampleViews(count: 10))
}
@MainActor func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
// Use sample data for widget picker preview, real data otherwise
let timeLineViews: [WatchTimelineView]
if context.isPreview {
timeLineViews = TimeLineCreator.createSampleViews(count: 10)
} else {
timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
}
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
let entry = SimpleEntry(date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: timeLineViews,
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
promptText: promptText)
completion(entry)
}
@MainActor func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
let entry = SimpleEntry(date: Calendar.current.date(byAdding: .second, value: 15, to: Date())!,
configuration: ConfigurationIntent(),
timeLineViews: nil,
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
promptText: promptText)
let midNightEntry = SimpleEntry(date: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date())!,
configuration: ConfigurationIntent(),
timeLineViews: nil,
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
promptText: promptText)
let date = Calendar.current.date(byAdding: .second, value: 10, to: Date())!
let timeline = Timeline(entries: [entry, midNightEntry], policy: .after(date))
completion(timeline)
}
@MainActor
private func checkSubscriptionAndVoteStatus() -> (hasSubscription: Bool, hasVotedToday: Bool, promptText: String) {
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
let dayStart = Calendar.current.startOfDay(for: votingDate)
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart) ?? dayStart
// Use WidgetDataProvider for isolated widget data access
let todayEntry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder
let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body
return (hasSubscription, hasVotedToday, promptText)
}
}
struct SimpleEntry: TimelineEntry {
let date: Date
let configuration: ConfigurationIntent
let timeLineViews: [WatchTimelineView]?
let showStats: Bool
let hasSubscription: Bool
let hasVotedToday: Bool
let promptText: String
init(date: Date, configuration: ConfigurationIntent, timeLineViews: [WatchTimelineView]?, showStats: Bool = false, hasSubscription: Bool = false, hasVotedToday: Bool = true, promptText: String = "") {
self.date = date
self.configuration = configuration
self.timeLineViews = timeLineViews
self.showStats = showStats
self.hasSubscription = hasSubscription
self.hasVotedToday = hasVotedToday
self.promptText = promptText
}
}
/**********************************************************/
struct FeelsWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
@ViewBuilder
var body: some View {
Group {
switch family {
case .systemSmall:
SmallWidgetView(entry: entry)
case .systemMedium:
MediumWidgetView(entry: entry)
case .systemLarge:
LargeWidgetView(entry: entry)
case .systemExtraLarge:
LargeWidgetView(entry: entry)
case .accessoryCircular, .accessoryRectangular, .accessoryInline:
SmallWidgetView(entry: entry)
@unknown default:
MediumWidgetView(entry: entry)
}
}
.containerBackground(showVotingForToday ? Color.clear : Color(UIColor.systemBackground), for: .widget)
}
}
struct SmallWidgetView: View {
var entry: Provider.Entry
var todayView: WatchTimelineView?
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
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)
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
todayView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first
}
var body: some View {
if showVotingForToday {
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemSmall, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else 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()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
struct MediumWidgetView: View {
var entry: Provider.Entry
var timeLineView = [WatchTimelineView]()
private var showVotingForToday: Bool {
!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))
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
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 {
if showVotingForToday {
// Show interactive voting buttons (or open app links if expired)
VotingView(family: .systemMedium, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = geo.size.height - 36
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)
}
}
}
}
}
// 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]()
private var showVotingForToday: Bool {
!entry.hasVotedToday
}
init(entry: Provider.Entry) {
self.entry = entry
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
let hasRealData = realData.contains { view in
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
return view.color != moodTint.color(forMood: .missing)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
}
private var dayFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "EEE"
return f
}
private var dateFormatter: DateFormatter {
let f = DateFormatter()
f.dateFormat = "d"
return f
}
var body: some View {
if showVotingForToday {
// Show interactive voting buttons for large widget (or open app links if expired)
LargeVotingView(promptText: entry.promptText, hasSubscription: entry.hasSubscription)
} else {
GeometryReader { geo in
let cellHeight = (geo.size.height - 70) / 2 // Subtract header height, divide by 2 rows
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)
// 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
)
}
}
// 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
)
}
}
}
.padding(.horizontal, 10)
.padding(.bottom, 8)
}
}
}
}
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)
}
}
// 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 {
VStack(spacing: 24) {
Spacer()
Text(hasSubscription ? promptText : "Subscribe to track your mood")
.font(.title2.weight(.semibold))
.foregroundStyle(.primary)
.multilineTextAlignment(.center)
.lineLimit(2)
.minimumScaleFactor(0.8)
// Large mood buttons in a row
HStack(spacing: 20) {
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
moodButton(for: mood)
}
}
Spacer()
}
.padding()
}
@ViewBuilder
private func moodButton(for mood: Mood) -> some View {
if hasSubscription {
Button(intent: VoteMoodIntent(mood: mood)) {
moodButtonContent(for: mood)
}
.buttonStyle(.plain)
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodButtonContent(for: mood)
}
}
}
private func moodButtonContent(for mood: Mood) -> some View {
VStack(spacing: 8) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.foregroundColor(moodTint.color(forMood: mood))
Text(mood.strValue)
.font(.caption.weight(.medium))
.foregroundColor(moodTint.color(forMood: mood))
}
.padding(.vertical, 12)
.padding(.horizontal, 8)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(moodTint.color(forMood: mood).opacity(0.15))
)
}
}
/**********************************************************/
struct FeelsGraphicWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallGraphicWidgetView(entry: entry)
}
}
struct SmallGraphicWidgetView: View {
var entry: Provider.Entry
var timeLineView: [WatchTimelineView]
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)
}
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 2)
}
private var iconViewModel: IconViewModel {
if let first = timeLineView.first {
return IconViewModel(backgroundImage: first.graphic,
bgColor: first.color,
bgOverlayColor: first.secondaryColor,
centerImage: first.graphic,
innerColor: first.color)
} else {
return IconViewModel.great
}
}
var body: some View {
Color.clear
.containerBackground(for: .widget) {
IconView(iconViewModel: iconViewModel)
}
}
}
/**********************************************************/
struct FeelsIconWidgetEntryView : View {
@Environment(\.sizeCategory) var sizeCategory
@Environment(\.widgetFamily) var family
var entry: Provider.Entry
@ViewBuilder
var body: some View {
SmallIconView(entry: entry)
}
}
struct SmallIconView: View {
var entry: Provider.Entry
private var customWidget: CustomWidgetModel {
UserDefaultsStore.getCustomWidgets().first(where: { $0.inUse == true })
?? CustomWidgetModel.randomWidget
}
var body: some View {
CustomWidgetView(customWidgetModel: customWidget)
.ignoresSafeArea()
.containerBackground(for: .widget) {
customWidget.bgColor
}
}
}
/**********************************************************/
struct TimeHeaderView: View {
let startDate: Date
let endDate: Date
var formatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter
}
var body: some View {
HStack {
Text(startDate, formatter: formatter)
.font(.system(.footnote))
Text(" - ")
.font(.system(.footnote))
Text(endDate, formatter: formatter)
.font(.system(.footnote))
}
}
}
struct TimeBodyView: View {
let group: [WatchTimelineView]
var showVotingForToday: Bool = false
var promptText: String = ""
var hasSubscription: Bool = false
var body: some View {
if showVotingForToday {
// Show voting view without extra background container
InlineVotingView(promptText: promptText, hasSubscription: hasSubscription)
.padding()
} else {
ZStack {
Color(UIColor.secondarySystemBackground)
HStack(spacing: 4) {
ForEach(group) { watchView in
EntryCard(timeLineView: watchView)
}
}
.padding()
}
}
}
}
// 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)
} else {
Link(destination: URL(string: "feels://subscribe")!) {
moodIcon(for: mood)
}
}
}
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))
}
}
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)
}
}
@main
struct FeelsBundle: WidgetBundle {
var body: some Widget {
FeelsWidget()
FeelsGraphicWidget()
FeelsIconWidget()
FeelsVoteWidget()
FeelsMoodControlWidget()
MoodStreakLiveActivity()
}
}
// MARK: - Control Center Widget
struct FeelsMoodControlWidget: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "FeelsMoodControl") {
ControlWidgetButton(action: OpenFeelsIntent()) {
Label("Log Mood", systemImage: "face.smiling")
}
}
.displayName("Log Mood")
.description("Open Feels to log your mood")
}
}
struct OpenFeelsIntent: AppIntent {
static var title: LocalizedStringResource = "Open Feels"
static var description = IntentDescription("Open the Feels app to log your mood")
static var openAppWhenRun: Bool = true
func perform() async throws -> some IntentResult {
return .result()
}
}
struct FeelsWidget: Widget {
let kind: String = "FeelsWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsWidgetEntryView(entry: entry)
}
.configurationDisplayName("Feels")
.description("")
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
}
}
struct FeelsIconWidget: Widget {
let kind: String = "FeelsIconWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsIconWidgetEntryView(entry: entry)
}
.configurationDisplayName("Feels Icon")
.description("")
.supportedFamilies([.systemSmall])
.contentMarginsDisabled()
}
}
struct FeelsGraphicWidget: Widget {
let kind: String = "FeelsGraphicWidget"
var body: some WidgetConfiguration {
IntentConfiguration(kind: kind,
intent: ConfigurationIntent.self,
provider: Provider()) { entry in
FeelsGraphicWidgetEntryView(entry: entry)
}
.configurationDisplayName("Mood Graphic")
.description("")
.supportedFamilies([.systemSmall])
.contentMarginsDisabled()
}
}
// MARK: - Preview Helpers
private extension FeelsWidget_Previews {
static func sampleTimelineViews(count: Int) -> [WatchTimelineView] {
let moods: [Mood] = [.great, .good, .average, .bad, .horrible]
return (0..<count).map { index in
let mood = moods[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)
)
}
}
static func sampleEntry(timelineCount: Int = 5) -> SimpleEntry {
SimpleEntry(
date: Date(),
configuration: ConfigurationIntent(),
timeLineViews: sampleTimelineViews(count: timelineCount),
hasSubscription: true,
hasVotedToday: true
)
}
}
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")
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)")
}
}
}