diff --git a/FeelsWidget2/FeelsGraphicWidget.swift b/FeelsWidget2/FeelsGraphicWidget.swift new file mode 100644 index 0000000..ceb69d0 --- /dev/null +++ b/FeelsWidget2/FeelsGraphicWidget.swift @@ -0,0 +1,94 @@ +// +// FeelsGraphicWidget.swift +// FeelsWidget +// +// Graphic mood widget (small only) +// + +import WidgetKit +import SwiftUI +import Intents + +// MARK: - Widget Configuration + +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: - Entry View + +struct FeelsGraphicWidgetEntryView: View { + @Environment(\.sizeCategory) var sizeCategory + @Environment(\.widgetFamily) var family + + var entry: Provider.Entry + + @ViewBuilder + var body: some View { + SmallGraphicWidgetView(entry: entry) + } +} + +// MARK: - Small Graphic Widget View + +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) + } + } +} + +// MARK: - Previews + +#Preview("Graphic - Great", as: .systemSmall) { + FeelsGraphicWidget() +} timeline: { + SimpleEntry( + date: Date(), + configuration: ConfigurationIntent(), + timeLineViews: nil, + hasSubscription: true, + hasVotedToday: true, + promptText: "" + ) +} diff --git a/FeelsWidget2/FeelsIconWidget.swift b/FeelsWidget2/FeelsIconWidget.swift new file mode 100644 index 0000000..d5d636b --- /dev/null +++ b/FeelsWidget2/FeelsIconWidget.swift @@ -0,0 +1,76 @@ +// +// FeelsIconWidget.swift +// FeelsWidget +// +// Custom icon widget (small only) +// + +import WidgetKit +import SwiftUI +import Intents + +// MARK: - Widget Configuration + +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() + } +} + +// MARK: - Entry View + +struct FeelsIconWidgetEntryView: View { + @Environment(\.sizeCategory) var sizeCategory + @Environment(\.widgetFamily) var family + + var entry: Provider.Entry + + @ViewBuilder + var body: some View { + SmallIconView(entry: entry) + } +} + +// MARK: - Small Icon View + +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 + } + } +} + +// MARK: - Preview + +#Preview("Custom Icon", as: .systemSmall) { + FeelsIconWidget() +} timeline: { + SimpleEntry( + date: Date(), + configuration: ConfigurationIntent(), + timeLineViews: nil, + hasSubscription: true, + hasVotedToday: true, + promptText: "" + ) +} diff --git a/FeelsWidget2/FeelsLiveActivity.swift b/FeelsWidget2/FeelsLiveActivity.swift new file mode 100644 index 0000000..dc198a1 --- /dev/null +++ b/FeelsWidget2/FeelsLiveActivity.swift @@ -0,0 +1,265 @@ +// +// FeelsLiveActivity.swift +// FeelsWidget +// +// Live Activity for mood streak tracking (Dynamic Island + Lock Screen) +// + +import WidgetKit +import SwiftUI +import ActivityKit + +// 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) + } + } + } +} + +// MARK: - Lock Screen View + +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)) + } +} + +// MARK: - Preview Sample Data + +extension MoodStreakAttributes { + static var preview: MoodStreakAttributes { + MoodStreakAttributes(startDate: Date()) + } +} + +extension MoodStreakAttributes.ContentState { + static var notLogged: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 7, + lastMoodLogged: "None", + lastMoodColor: "#888888", + hasLoggedToday: false, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } + + static var loggedGreat: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 15, + lastMoodLogged: "Great", + lastMoodColor: MoodTints.Default.color(forMood: .great).toHex() ?? "#4CAF50", + hasLoggedToday: true, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } + + static var loggedGood: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 30, + lastMoodLogged: "Good", + lastMoodColor: MoodTints.Default.color(forMood: .good).toHex() ?? "#8BC34A", + hasLoggedToday: true, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } + + static var loggedAverage: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 10, + lastMoodLogged: "Average", + lastMoodColor: MoodTints.Default.color(forMood: .average).toHex() ?? "#FFC107", + hasLoggedToday: true, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } + + static var loggedBad: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 5, + lastMoodLogged: "Bad", + lastMoodColor: MoodTints.Default.color(forMood: .bad).toHex() ?? "#FF9800", + hasLoggedToday: true, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } + + static var loggedHorrible: MoodStreakAttributes.ContentState { + MoodStreakAttributes.ContentState( + currentStreak: 3, + lastMoodLogged: "Horrible", + lastMoodColor: MoodTints.Default.color(forMood: .horrible).toHex() ?? "#F44336", + hasLoggedToday: true, + votingWindowEnd: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date()) ?? Date() + ) + } +} + +// MARK: - Live Activity Previews + +#Preview("Lock Screen - Not Logged", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.notLogged +} + +#Preview("Lock Screen - Great", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedGreat +} + +#Preview("Lock Screen - Good", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedGood +} + +#Preview("Lock Screen - Average", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedAverage +} + +#Preview("Lock Screen - Bad", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedBad +} + +#Preview("Lock Screen - Horrible", as: .content, using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedHorrible +} + +// MARK: - Dynamic Island Previews + +#Preview("Dynamic Island Expanded - Not Logged", as: .dynamicIsland(.expanded), using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.notLogged +} + +#Preview("Dynamic Island Expanded - Logged", as: .dynamicIsland(.expanded), using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedGreat +} + +#Preview("Dynamic Island Compact", as: .dynamicIsland(.compact), using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedGreat +} + +#Preview("Dynamic Island Minimal", as: .dynamicIsland(.minimal), using: MoodStreakAttributes.preview) { + MoodStreakLiveActivity() +} contentStates: { + MoodStreakAttributes.ContentState.loggedGreat +} diff --git a/FeelsWidget2/FeelsMoodControlWidget.swift b/FeelsWidget2/FeelsMoodControlWidget.swift new file mode 100644 index 0000000..1613d2b --- /dev/null +++ b/FeelsWidget2/FeelsMoodControlWidget.swift @@ -0,0 +1,36 @@ +// +// FeelsMoodControlWidget.swift +// FeelsWidget +// +// Control Center widget for quick mood logging +// + +import WidgetKit +import SwiftUI +import AppIntents + +// 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") + } +} + +// MARK: - Open App Intent + +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() + } +} diff --git a/FeelsWidget2/FeelsTimelineWidget.swift b/FeelsWidget2/FeelsTimelineWidget.swift new file mode 100644 index 0000000..fa46567 --- /dev/null +++ b/FeelsWidget2/FeelsTimelineWidget.swift @@ -0,0 +1,578 @@ +// +// FeelsTimelineWidget.swift +// FeelsWidget +// +// Timeline widget showing mood history (small, medium, large) +// + +import WidgetKit +import SwiftUI +import Intents + +// MARK: - Widget Configuration + +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]) + } +} + +// MARK: - Entry View Router + +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) + } +} + +// MARK: - Small Widget View + +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) + } + } +} + +// MARK: - Medium Widget View + +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: - Medium Day Cell + +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) + } +} + +// MARK: - Large Widget View + +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))" + } +} + +// 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: - Supporting Views + +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() + } + } + } +} + +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) + } +} + +// 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: - Previews + +// 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) +} diff --git a/FeelsWidget2/FeelsVoteWidget.swift b/FeelsWidget2/FeelsVoteWidget.swift index 4e55333..1a92712 100644 --- a/FeelsWidget2/FeelsVoteWidget.swift +++ b/FeelsWidget2/FeelsVoteWidget.swift @@ -4,153 +4,27 @@ // // Interactive widget for mood voting (iOS 17+) // -// Note: VoteMoodIntent is defined in Shared/SharedMoodIntent.swift -// import WidgetKit import SwiftUI import AppIntents -// MARK: - Vote Widget Provider +// MARK: - Widget Configuration -struct VoteWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> VoteWidgetEntry { - // Show sample "already voted" state for widget picker preview - let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) - let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body - return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) - } +struct FeelsVoteWidget: Widget { + let kind: String = "FeelsVoteWidget" - func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { - // Show sample data for widget picker preview - if context.isPreview { - let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) - let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body - let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) - completion(entry) - return + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in + FeelsVoteWidgetEntryView(entry: entry) } - Task { @MainActor in - let entry = createEntry() - completion(entry) - } - } - - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - Task { @MainActor in - let entry = createEntry() - - // Calculate next refresh time - let nextRefresh = calculateNextRefreshDate() - let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) - completion(timeline) - } - } - - /// Calculate when the widget should next refresh - /// Refreshes at: rating time (to show voting view) and midnight (for new day) - private func calculateNextRefreshDate() -> Date { - let now = Date() - let calendar = Calendar.current - - // Get the rating time from onboarding data - let onboardingData = UserDefaultsStore.getOnboarding() - let ratingTimeComponents = calendar.dateComponents([.hour, .minute], from: onboardingData.date) - - // Create today's rating time - var todayRatingComponents = calendar.dateComponents([.year, .month, .day], from: now) - todayRatingComponents.hour = ratingTimeComponents.hour - todayRatingComponents.minute = ratingTimeComponents.minute - let todayRatingTime = calendar.date(from: todayRatingComponents) ?? now - - // Tomorrow's midnight - let midnight = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!) - - // If we haven't passed today's rating time, refresh at rating time - if now < todayRatingTime { - return todayRatingTime - } - - // Otherwise refresh at midnight - return midnight - } - - @MainActor - private func createEntry() -> VoteWidgetEntry { - let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) - - // Use WidgetDataProvider for isolated read-only data access - let dataProvider = WidgetDataProvider.shared - - 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)! - - // Check if user has voted today - let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first - let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder - - // Get today's mood if voted - let todaysMood: Mood? = hasVotedToday ? todayEntry?.mood : nil - - // Get stats for display after voting - var stats: MoodStats? = nil - if hasVotedToday { - let allEntries = dataProvider.getData( - startDate: Date(timeIntervalSince1970: 0), - endDate: Date(), - includedDays: [] - ) - let validEntries = allEntries.filter { $0.mood != Mood.missing && $0.mood != Mood.placeholder } - let totalCount = validEntries.count - - if totalCount > 0 { - var moodCounts: [Mood: Int] = [:] - for entry in validEntries { - moodCounts[entry.mood, default: 0] += 1 - } - stats = MoodStats(totalEntries: totalCount, moodCounts: moodCounts) - } - } - - // Get random prompt text for voting view - let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body - - return VoteWidgetEntry( - date: Date(), - hasSubscription: hasSubscription, - hasVotedToday: hasVotedToday, - todaysMood: todaysMood, - stats: stats, - promptText: promptText - ) + .configurationDisplayName("Mood Vote") + .description("Quickly rate your mood for today") + .supportedFamilies([.systemSmall, .systemMedium]) } } -// MARK: - Stats Model - -struct MoodStats { - let totalEntries: Int - let moodCounts: [Mood: Int] - - func percentage(for mood: Mood) -> Double { - guard totalEntries > 0 else { return 0 } - return Double(moodCounts[mood, default: 0]) / Double(totalEntries) * 100 - } -} - -// MARK: - Timeline Entry - -struct VoteWidgetEntry: TimelineEntry { - let date: Date - let hasSubscription: Bool - let hasVotedToday: Bool - let todaysMood: Mood? - let stats: MoodStats? - let promptText: String -} - -// MARK: - Widget Views +// MARK: - Entry View struct FeelsVoteWidgetEntryView: View { @Environment(\.widgetFamily) var family @@ -172,145 +46,6 @@ struct FeelsVoteWidgetEntryView: View { } } -// MARK: - Voting View - -struct VotingView: View { - let family: WidgetFamily - let promptText: String - let hasSubscription: Bool - - private var moodTint: MoodTintable.Type { - UserDefaultsStore.moodTintable() - } - - private var moodImages: MoodImagable.Type { - UserDefaultsStore.moodMoodImagable() - } - - var body: some View { - if family == .systemSmall { - smallLayout - } else { - mediumLayout - } - } - - // MARK: - Small Widget: 3 over 2 grid - private var smallLayout: some View { - VStack(spacing: 0) { - Text(hasSubscription ? promptText : "Tap to open app") - .font(.caption) - .foregroundStyle(.primary) - .multilineTextAlignment(.center) - .lineLimit(1) - .minimumScaleFactor(0.7) - .padding(.bottom, 10) - - // Top row: Great, Good, Average - HStack(spacing: 12) { - ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in - moodButton(for: mood, size: 36) - } - } - .padding(.bottom, 6) - - // Bottom row: Bad, Horrible - HStack(spacing: 12) { - ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in - moodButton(for: mood, size: 36) - } - } - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - - // MARK: - Medium Widget: Single row - private var mediumLayout: some View { - VStack(spacing: 12) { - Text(hasSubscription ? promptText : "Subscribe to track your mood") - .font(.headline) - .foregroundStyle(.primary) - .multilineTextAlignment(.center) - .lineLimit(2) - .minimumScaleFactor(0.8) - - HStack(spacing: 0) { - ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in - moodButtonMedium(for: mood) - .frame(maxWidth: .infinity) - } - } - } - .padding(.horizontal, 12) - .padding(.vertical, 16) - } - - @ViewBuilder - private func moodButton(for mood: Mood, size: CGFloat) -> some View { - // Used for small widget - let touchSize = max(size, 44) - - if hasSubscription { - Button(intent: VoteMoodIntent(mood: mood)) { - moodIcon(for: mood, size: size) - .frame(minWidth: touchSize, minHeight: touchSize) - } - .buttonStyle(.plain) - .accessibilityLabel(mood.strValue) - .accessibilityHint(String(localized: "Log this mood")) - } else { - Link(destination: URL(string: "feels://subscribe")!) { - moodIcon(for: mood, size: size) - .frame(minWidth: touchSize, minHeight: touchSize) - } - .accessibilityLabel(mood.strValue) - .accessibilityHint(String(localized: "Open app to subscribe")) - } - } - - @ViewBuilder - private func moodButtonMedium(for mood: Mood) -> some View { - // Medium widget uses smaller icons with labels, flexible width - let content = VStack(spacing: 4) { - moodImages.icon(forMood: mood) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 32, height: 32) - .foregroundColor(moodTint.color(forMood: mood)) - - Text(mood.widgetDisplayName) - .font(.caption2) - .foregroundColor(moodTint.color(forMood: mood)) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - - if hasSubscription { - Button(intent: VoteMoodIntent(mood: mood)) { - content - } - .buttonStyle(.plain) - .accessibilityLabel(mood.strValue) - .accessibilityHint(String(localized: "Log this mood")) - } else { - Link(destination: URL(string: "feels://subscribe")!) { - content - } - .accessibilityLabel(mood.strValue) - .accessibilityHint(String(localized: "Open app to subscribe")) - } - } - - private func moodIcon(for mood: Mood, size: CGFloat) -> some View { - moodImages.icon(forMood: mood) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: size, height: size) - .foregroundColor(moodTint.color(forMood: mood)) - } -} - // MARK: - Voted Stats View (shown after voting) struct VotedStatsView: View { @@ -467,21 +202,6 @@ struct NonSubscriberView: View { } } -// MARK: - Widget Configuration - -struct FeelsVoteWidget: Widget { - let kind: String = "FeelsVoteWidget" - - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: VoteWidgetProvider()) { entry in - FeelsVoteWidgetEntryView(entry: entry) - } - .configurationDisplayName("Mood Vote") - .description("Quickly rate your mood for today") - .supportedFamilies([.systemSmall, .systemMedium]) - } -} - // MARK: - Preview Helpers private enum VoteWidgetPreviewHelpers { diff --git a/FeelsWidget2/FeelsWidget.swift b/FeelsWidget2/FeelsWidget.swift deleted file mode 100644 index 5598519..0000000 --- a/FeelsWidget2/FeelsWidget.swift +++ /dev/null @@ -1,1401 +0,0 @@ -// -// 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)) -} diff --git a/FeelsWidget2/WidgetBundle.swift b/FeelsWidget2/WidgetBundle.swift new file mode 100644 index 0000000..839f951 --- /dev/null +++ b/FeelsWidget2/WidgetBundle.swift @@ -0,0 +1,21 @@ +// +// WidgetBundle.swift +// FeelsWidget +// +// Main widget bundle that registers all Feels widgets +// + +import WidgetKit +import SwiftUI + +@main +struct FeelsBundle: WidgetBundle { + var body: some Widget { + FeelsWidget() + FeelsGraphicWidget() + FeelsIconWidget() + FeelsVoteWidget() + FeelsMoodControlWidget() + MoodStreakLiveActivity() + } +} diff --git a/FeelsWidget2/WidgetModels.swift b/FeelsWidget2/WidgetModels.swift new file mode 100644 index 0000000..c13f879 --- /dev/null +++ b/FeelsWidget2/WidgetModels.swift @@ -0,0 +1,76 @@ +// +// WidgetModels.swift +// FeelsWidget +// +// Data models for widget timeline entries +// + +import WidgetKit +import SwiftUI +import Intents + +// MARK: - Timeline View Model + +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 + } +} + +// MARK: - Timeline Widget Entry + +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 + } +} + +// MARK: - Vote Widget Entry + +struct VoteWidgetEntry: TimelineEntry { + let date: Date + let hasSubscription: Bool + let hasVotedToday: Bool + let todaysMood: Mood? + let stats: MoodStats? + let promptText: String +} + +// MARK: - Mood Stats + +struct MoodStats { + let totalEntries: Int + let moodCounts: [Mood: Int] + + func percentage(for mood: Mood) -> Double { + guard totalEntries > 0 else { return 0 } + return Double(moodCounts[mood, default: 0]) / Double(totalEntries) * 100 + } +} diff --git a/FeelsWidget2/WidgetProviders.swift b/FeelsWidget2/WidgetProviders.swift new file mode 100644 index 0000000..c81777f --- /dev/null +++ b/FeelsWidget2/WidgetProviders.swift @@ -0,0 +1,264 @@ +// +// WidgetProviders.swift +// FeelsWidget +// +// Timeline providers for widget data +// + +import WidgetKit +import SwiftUI +import Intents + +// MARK: - Timeline Creator + +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) + } +} + +// MARK: - Vote Widget Provider + +struct VoteWidgetProvider: TimelineProvider { + func placeholder(in context: Context) -> VoteWidgetEntry { + // Show sample "already voted" state for widget picker preview + let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + return VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) + } + + func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) { + // Show sample data for widget picker preview + if context.isPreview { + let sampleStats = MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]) + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + let entry = VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .good, stats: sampleStats, promptText: promptText) + completion(entry) + return + } + Task { @MainActor in + let entry = createEntry() + completion(entry) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + Task { @MainActor in + let entry = createEntry() + + // Calculate next refresh time + let nextRefresh = calculateNextRefreshDate() + let timeline = Timeline(entries: [entry], policy: .after(nextRefresh)) + completion(timeline) + } + } + + /// Calculate when the widget should next refresh + /// Refreshes at: rating time (to show voting view) and midnight (for new day) + private func calculateNextRefreshDate() -> Date { + let now = Date() + let calendar = Calendar.current + + // Get the rating time from onboarding data + let onboardingData = UserDefaultsStore.getOnboarding() + let ratingTimeComponents = calendar.dateComponents([.hour, .minute], from: onboardingData.date) + + // Create today's rating time + var todayRatingComponents = calendar.dateComponents([.year, .month, .day], from: now) + todayRatingComponents.hour = ratingTimeComponents.hour + todayRatingComponents.minute = ratingTimeComponents.minute + let todayRatingTime = calendar.date(from: todayRatingComponents) ?? now + + // Tomorrow's midnight + let midnight = calendar.startOfDay(for: calendar.date(byAdding: .day, value: 1, to: now)!) + + // If we haven't passed today's rating time, refresh at rating time + if now < todayRatingTime { + return todayRatingTime + } + + // Otherwise refresh at midnight + return midnight + } + + @MainActor + private func createEntry() -> VoteWidgetEntry { + let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue) + + // Use WidgetDataProvider for isolated read-only data access + let dataProvider = WidgetDataProvider.shared + + 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)! + + // Check if user has voted today + let todayEntry = dataProvider.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first + let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder + + // Get today's mood if voted + let todaysMood: Mood? = hasVotedToday ? todayEntry?.mood : nil + + // Get stats for display after voting + var stats: MoodStats? = nil + if hasVotedToday { + let allEntries = dataProvider.getData( + startDate: Date(timeIntervalSince1970: 0), + endDate: Date(), + includedDays: [] + ) + let validEntries = allEntries.filter { $0.mood != Mood.missing && $0.mood != Mood.placeholder } + let totalCount = validEntries.count + + if totalCount > 0 { + var moodCounts: [Mood: Int] = [:] + for entry in validEntries { + moodCounts[entry.mood, default: 0] += 1 + } + stats = MoodStats(totalEntries: totalCount, moodCounts: moodCounts) + } + } + + // Get random prompt text for voting view + let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body + + return VoteWidgetEntry( + date: Date(), + hasSubscription: hasSubscription, + hasVotedToday: hasVotedToday, + todaysMood: todaysMood, + stats: stats, + promptText: promptText + ) + } +} diff --git a/FeelsWidget2/WidgetSharedViews.swift b/FeelsWidget2/WidgetSharedViews.swift new file mode 100644 index 0000000..b160de9 --- /dev/null +++ b/FeelsWidget2/WidgetSharedViews.swift @@ -0,0 +1,289 @@ +// +// WidgetSharedViews.swift +// FeelsWidget +// +// Shared voting views used across multiple widgets +// + +import WidgetKit +import SwiftUI +import AppIntents + +// MARK: - Voting View + +struct VotingView: View { + let family: WidgetFamily + let promptText: String + let hasSubscription: Bool + + private var moodTint: MoodTintable.Type { + UserDefaultsStore.moodTintable() + } + + private var moodImages: MoodImagable.Type { + UserDefaultsStore.moodMoodImagable() + } + + var body: some View { + if family == .systemSmall { + smallLayout + } else { + mediumLayout + } + } + + // MARK: - Small Widget: 3 over 2 grid + private var smallLayout: some View { + VStack(spacing: 0) { + Text(hasSubscription ? promptText : "Tap to open app") + .font(.caption) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(1) + .minimumScaleFactor(0.7) + .padding(.bottom, 10) + + // Top row: Great, Good, Average + HStack(spacing: 12) { + ForEach([Mood.great, .good, .average], id: \.rawValue) { mood in + moodButton(for: mood, size: 36) + } + } + .padding(.bottom, 6) + + // Bottom row: Bad, Horrible + HStack(spacing: 12) { + ForEach([Mood.bad, .horrible], id: \.rawValue) { mood in + moodButton(for: mood, size: 36) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + + // MARK: - Medium Widget: Single row + private var mediumLayout: some View { + VStack(spacing: 12) { + Text(hasSubscription ? promptText : "Subscribe to track your mood") + .font(.headline) + .foregroundStyle(.primary) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + + HStack(spacing: 0) { + ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in + moodButtonMedium(for: mood) + .frame(maxWidth: .infinity) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 16) + } + + @ViewBuilder + private func moodButton(for mood: Mood, size: CGFloat) -> some View { + // Used for small widget + let touchSize = max(size, 44) + + if hasSubscription { + Button(intent: VoteMoodIntent(mood: mood)) { + moodIcon(for: mood, size: size) + .frame(minWidth: touchSize, minHeight: touchSize) + } + .buttonStyle(.plain) + .accessibilityLabel(mood.strValue) + .accessibilityHint(String(localized: "Log this mood")) + } else { + Link(destination: URL(string: "feels://subscribe")!) { + moodIcon(for: mood, size: size) + .frame(minWidth: touchSize, minHeight: touchSize) + } + .accessibilityLabel(mood.strValue) + .accessibilityHint(String(localized: "Open app to subscribe")) + } + } + + @ViewBuilder + private func moodButtonMedium(for mood: Mood) -> some View { + // Medium widget uses smaller icons with labels, flexible width + let content = VStack(spacing: 4) { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 32, height: 32) + .foregroundColor(moodTint.color(forMood: mood)) + + Text(mood.widgetDisplayName) + .font(.caption2) + .foregroundColor(moodTint.color(forMood: mood)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + + if hasSubscription { + Button(intent: VoteMoodIntent(mood: mood)) { + content + } + .buttonStyle(.plain) + .accessibilityLabel(mood.strValue) + .accessibilityHint(String(localized: "Log this mood")) + } else { + Link(destination: URL(string: "feels://subscribe")!) { + content + } + .accessibilityLabel(mood.strValue) + .accessibilityHint(String(localized: "Open app to subscribe")) + } + } + + private func moodIcon(for mood: Mood, size: CGFloat) -> some View { + moodImages.icon(forMood: mood) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .foregroundColor(moodTint.color(forMood: mood)) + } +} + +// 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)) + ) + } +} + +// 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)) + } +}