// // 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 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 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.graphic = graphic self.secondaryColor = secondaryColor self.mood = mood } } 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), 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), mood: .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.. 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) -> ()) { 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) .accessibilityLabel(today.mood.strValue) 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, mood: item.mood ) } } .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 let mood: Mood 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(.caption2.weight(isToday ? .bold : .medium)) .foregroundStyle(isToday ? .primary : .secondary) .textCase(.uppercase) image .resizable() .aspectRatio(contentMode: .fit) .frame(width: 36, height: 36) .foregroundColor(color) .accessibilityLabel(mood.strValue) Text(dateLabel) .font(.caption.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, mood: item.mood ) } } // 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, mood: item.mood ) } } } .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 let mood: Mood var body: some View { VStack(spacing: 2) { Text(dayLabel) .font(.caption2.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) .accessibilityLabel(mood.strValue) Text(dateLabel) .font(.caption.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: 16) { Spacer() Text(hasSubscription ? promptText : "Subscribe to track your mood") .font(.title3.weight(.semibold)) .foregroundStyle(.primary) .multilineTextAlignment(.center) .lineLimit(2) .minimumScaleFactor(0.8) .padding(.horizontal, 8) // 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(.horizontal, 12) .padding(.vertical, 16) } @ViewBuilder private func moodButton(for mood: Mood) -> some View { if hasSubscription { Button(intent: VoteMoodIntent(mood: mood)) { 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: 4) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 40, height: 40) .foregroundColor(moodTint.color(forMood: mood)) Text(mood.widgetDisplayName) .font(.caption2.weight(.medium)) .foregroundColor(moodTint.color(forMood: mood)) .lineLimit(1) .minimumScaleFactor(0.8) } .padding(.vertical, 8) .padding(.horizontal, 4) .background( RoundedRectangle(cornerRadius: 12) .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) .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")) } } 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) .accessibilityLabel(timeLineView.mood.strValue) } } @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 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.. SimpleEntry { SimpleEntry( date: Date(), configuration: ConfigurationIntent(), timeLineViews: sampleTimelineViews(count: timelineCount, startMood: startMood), hasSubscription: hasSubscription, hasVotedToday: hasVotedToday, promptText: "How are you feeling today?" ) } } // MARK: - FeelsWidget Previews (Timeline Widget) // 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)) }