// // 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 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)! }) 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 = DataController.shared.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.. 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 let todayEntry = DataController.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.hasSubscription && !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 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) } if let firstView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first { timeLineView = [firstView] } } var body: some View { ZStack { Color(UIColor.secondarySystemBackground) HStack { ForEach(self.timeLineView) { watchView in EntryCard(timeLineView: watchView) } } .padding() } .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) .frame(minHeight: 0, maxHeight: 55) .padding() } } struct MediumWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() private var showVotingForToday: Bool { entry.hasSubscription && !entry.hasVotedToday } 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) } timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5) } var body: some View { VStack { Spacer() if !showVotingForToday, let first = timeLineView.first, let last = timeLineView.last { TimeHeaderView(startDate: first.date, endDate: last.date) .frame(minWidth: 0, maxWidth: .infinity) .multilineTextAlignment(.leading) } 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() } } } struct LargeWidgetView: View { var entry: Provider.Entry var timeLineView = [WatchTimelineView]() private var showVotingForToday: Bool { entry.hasSubscription && !entry.hasVotedToday } 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) } timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10) } var firstGroup: [WatchTimelineView] { return Array(self.timeLineView.prefix(5)) } var secondGroup: [WatchTimelineView] { return Array(self.timeLineView.suffix(5)) } var body: some View { VStack { Spacer() // 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) } TimeBodyView(group: firstGroup, showVotingForToday: showVotingForToday, promptText: entry.promptText) .clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous)) .frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55) .padding() 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) } TimeBodyView(group: secondGroup, showVotingForToday: false) .clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous)) .frame(minHeight: 0, maxHeight: 55) .padding() Spacer() } Spacer() } } } /**********************************************************/ struct FeelsGraphicWidgetEntryView : View { @Environment(\.sizeCategory) var sizeCategory @Environment(\.widgetFamily) var family var entry: Provider.Entry @ViewBuilder var body: some View { SmallGraphicWidgetView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } } 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) } var body: some View { if let first = timeLineView.first { IconView(iconViewModel: IconViewModel(backgroundImage: first.graphic, bgColor: first.color, bgOverlayColor: first.secondaryColor, centerImage: first.graphic, innerColor: first.color)) } else { IconView(iconViewModel: IconViewModel.great) } } } /**********************************************************/ struct FeelsIconWidgetEntryView : View { @Environment(\.sizeCategory) var sizeCategory @Environment(\.widgetFamily) var family var entry: Provider.Entry @ViewBuilder var body: some View { SmallIconView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } } struct SmallIconView: View { var entry: Provider.Entry 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) } } } } /**********************************************************/ 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 body: some View { if showVotingForToday { // Show voting view without extra background container InlineVotingView(promptText: promptText) .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 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(promptText) .font(.subheadline) .foregroundStyle(.primary) .multilineTextAlignment(.center) .lineLimit(2) .minimumScaleFactor(0.7) HStack(spacing: 8) { ForEach(moods, id: \.rawValue) { mood in Button(intent: VoteMoodIntent(mood: mood)) { moodImages.icon(forMood: mood) .resizable() .aspectRatio(contentMode: .fit) .frame(width: 44, height: 44) .foregroundColor(moodTint.color(forMood: mood)) } .buttonStyle(.plain) } } } } } 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]) } } 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]) } } struct FeelsWidget_Previews: PreviewProvider { static var previews: some View { Group { FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great), graphic: HandEmojiMoodImages.icon(forMood: .great), date: Date(), color: MoodTints.Neon.color(forMood: .great), secondaryColor: .white), WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great), graphic: HandEmojiMoodImages.icon(forMood: .great), date: Date(), color: MoodTints.Neon.color(forMood: .great), secondaryColor: .white)])) .previewContext(WidgetPreviewContext(family: .systemSmall)) FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(), configuration: ConfigurationIntent(), timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible), graphic: HandEmojiMoodImages.icon(forMood: .horrible), date: Date(), color: MoodTints.Neon.color(forMood: .horrible), secondaryColor: .white), WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible), graphic: HandEmojiMoodImages.icon(forMood: .horrible), date: Date(), color: MoodTints.Neon.color(forMood: .horrible), secondaryColor: .white)])) .previewContext(WidgetPreviewContext(family: .systemSmall)) // FeelsWidgetEntryView(entry: SimpleEntry(date: Date(), // configuration: ConfigurationIntent(), // timeLineViews: FeelsWidget_Previews.data)) // .previewContext(WidgetPreviewContext(family: .systemMedium)) // .environment(\.sizeCategory, .medium) // // FeelsWidgetEntryView(entry: SimpleEntry(date: Date(), // configuration: ConfigurationIntent(), // timeLineViews: FeelsWidget_Previews.data)) // .previewContext(WidgetPreviewContext(family: .systemLarge)) // .environment(\.sizeCategory, .large) } } }