- Add HealthKit State of Mind sync for mood entries - Add Live Activity with streak display and rating time window - Add App Shortcuts/Siri integration for voice mood logging - Add TipKit hints for feature discovery - Add centralized MoodLogger for consistent side effects - Add reminder time setting in Settings with time picker - Fix duplicate notifications when changing reminder time - Fix Live Activity streak showing 0 when not yet rated today - Fix slow tap response in entry detail mood selection - Update widget timeline to refresh at rating time - Sync widgets when reminder time changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
774 lines
31 KiB
Swift
774 lines
31 KiB
Swift
//
|
|
// FeelsWidget.swift
|
|
// FeelsWidget
|
|
//
|
|
// Created by Trey Tartt on 1/7/22.
|
|
//
|
|
|
|
import WidgetKit
|
|
import SwiftUI
|
|
import Intents
|
|
import SwiftData
|
|
import ActivityKit
|
|
import AppIntents
|
|
|
|
// MARK: - Live Activity Widget
|
|
// Note: MoodStreakAttributes is defined in MoodStreakActivity.swift (Shared folder)
|
|
|
|
struct MoodStreakLiveActivity: Widget {
|
|
var body: some WidgetConfiguration {
|
|
ActivityConfiguration(for: MoodStreakAttributes.self) { context in
|
|
// Lock Screen / StandBy view
|
|
MoodStreakLockScreenView(context: context)
|
|
} dynamicIsland: { context in
|
|
DynamicIsland {
|
|
// Expanded view
|
|
DynamicIslandExpandedRegion(.leading) {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "flame.fill")
|
|
.foregroundColor(.orange)
|
|
Text("\(context.state.currentStreak)")
|
|
.font(.title2.bold())
|
|
}
|
|
}
|
|
|
|
DynamicIslandExpandedRegion(.trailing) {
|
|
if context.state.hasLoggedToday {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundColor(.green)
|
|
.font(.title2)
|
|
} else {
|
|
Text("Log now")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
DynamicIslandExpandedRegion(.center) {
|
|
Text(context.state.hasLoggedToday ? "Streak: \(context.state.currentStreak) days" : "Don't break your streak!")
|
|
.font(.headline)
|
|
}
|
|
|
|
DynamicIslandExpandedRegion(.bottom) {
|
|
if !context.state.hasLoggedToday {
|
|
Text("Voting closes at midnight")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
} else {
|
|
HStack {
|
|
Circle()
|
|
.fill(Color(hex: context.state.lastMoodColor))
|
|
.frame(width: 20, height: 20)
|
|
Text("Today: \(context.state.lastMoodLogged)")
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
}
|
|
} compactLeading: {
|
|
Image(systemName: "flame.fill")
|
|
.foregroundColor(.orange)
|
|
} compactTrailing: {
|
|
Text("\(context.state.currentStreak)")
|
|
.font(.caption.bold())
|
|
} minimal: {
|
|
Image(systemName: "flame.fill")
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MoodStreakLockScreenView: View {
|
|
let context: ActivityViewContext<MoodStreakAttributes>
|
|
|
|
var body: some View {
|
|
HStack(spacing: 16) {
|
|
// Streak indicator
|
|
VStack(spacing: 4) {
|
|
Image(systemName: "flame.fill")
|
|
.font(.title)
|
|
.foregroundColor(.orange)
|
|
Text("\(context.state.currentStreak)")
|
|
.font(.title.bold())
|
|
Text("day streak")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
Divider()
|
|
.frame(height: 50)
|
|
|
|
// Status
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if context.state.hasLoggedToday {
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(Color(hex: context.state.lastMoodColor))
|
|
.frame(width: 24, height: 24)
|
|
VStack(alignment: .leading) {
|
|
Text("Today's mood")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
Text(context.state.lastMoodLogged)
|
|
.font(.headline)
|
|
}
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading) {
|
|
Text("Don't break your streak!")
|
|
.font(.headline)
|
|
Text("Tap to log your mood")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.activityBackgroundTint(Color(.systemBackground).opacity(0.8))
|
|
}
|
|
}
|
|
|
|
class WatchTimelineView: Identifiable {
|
|
let id = UUID()
|
|
let image: Image
|
|
let graphic: Image
|
|
let date: Date
|
|
let color: Color
|
|
let secondaryColor: Color
|
|
|
|
init(image: Image, graphic: Image, date: Date, color: Color, secondaryColor: Color) {
|
|
self.image = image
|
|
self.date = date
|
|
self.color = color
|
|
self.graphic = graphic
|
|
self.secondaryColor = secondaryColor
|
|
}
|
|
}
|
|
|
|
struct TimeLineCreator {
|
|
@MainActor static func createViews(daysBack: Int) -> [WatchTimelineView] {
|
|
var timeLineView = [WatchTimelineView]()
|
|
|
|
let latestDayToShow = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
|
let dates = Array(0...daysBack).map({
|
|
Calendar.current.date(byAdding: .day, value: -$0, to: latestDayToShow)!
|
|
})
|
|
|
|
for date in dates {
|
|
let dayStart = Calendar.current.startOfDay(for: date)
|
|
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart)!
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
|
|
|
if let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first {
|
|
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: todayEntry.mood),
|
|
graphic: moodImages.icon(forMood: todayEntry.mood),
|
|
date: dayStart,
|
|
color: moodTint.color(forMood: todayEntry.mood),
|
|
secondaryColor: moodTint.secondary(forMood: todayEntry.mood)))
|
|
} else {
|
|
timeLineView.append(WatchTimelineView(image: moodImages.icon(forMood: .missing),
|
|
graphic: moodImages.icon(forMood: .missing),
|
|
date: dayStart,
|
|
color: moodTint.color(forMood: .missing),
|
|
secondaryColor: moodTint.secondary(forMood: .missing)))
|
|
}
|
|
}
|
|
|
|
timeLineView = timeLineView.sorted(by: { $0.date > $1.date })
|
|
return timeLineView
|
|
}
|
|
|
|
/// Creates sample preview data for widget picker - shows what widget looks like with mood data
|
|
static func createSampleViews(count: Int) -> [WatchTimelineView] {
|
|
var timeLineView = [WatchTimelineView]()
|
|
let sampleMoods: [Mood] = [.great, .good, .average, .good, .great, .average, .bad, .good, .great, .good, .average]
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
let moodImages: MoodImagable.Type = UserDefaultsStore.moodMoodImagable()
|
|
|
|
for i in 0..<count {
|
|
let date = Calendar.current.date(byAdding: .day, value: -i, to: Date())!
|
|
let dayStart = Calendar.current.startOfDay(for: date)
|
|
let mood = sampleMoods[i % sampleMoods.count]
|
|
|
|
timeLineView.append(WatchTimelineView(
|
|
image: moodImages.icon(forMood: mood),
|
|
graphic: moodImages.icon(forMood: mood),
|
|
date: dayStart,
|
|
color: moodTint.color(forMood: mood),
|
|
secondaryColor: moodTint.secondary(forMood: mood)
|
|
))
|
|
}
|
|
|
|
return timeLineView
|
|
}
|
|
}
|
|
|
|
struct Provider: @preconcurrency IntentTimelineProvider {
|
|
let timeLineCreator = TimeLineCreator()
|
|
|
|
/*
|
|
placeholder for widget, no data
|
|
gets redacted auto - uses sample data for widget picker preview
|
|
*/
|
|
func placeholder(in context: Context) -> SimpleEntry {
|
|
return SimpleEntry(date: Date(),
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: TimeLineCreator.createSampleViews(count: 10))
|
|
}
|
|
|
|
@MainActor func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
|
// Use sample data for widget picker preview, real data otherwise
|
|
let timeLineViews: [WatchTimelineView]
|
|
if context.isPreview {
|
|
timeLineViews = TimeLineCreator.createSampleViews(count: 10)
|
|
} else {
|
|
timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
|
}
|
|
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
|
|
let entry = SimpleEntry(date: Date(),
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: timeLineViews,
|
|
hasSubscription: hasSubscription,
|
|
hasVotedToday: hasVotedToday,
|
|
promptText: promptText)
|
|
completion(entry)
|
|
}
|
|
|
|
@MainActor func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
|
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
|
|
let entry = SimpleEntry(date: Calendar.current.date(byAdding: .second, value: 15, to: Date())!,
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: nil,
|
|
hasSubscription: hasSubscription,
|
|
hasVotedToday: hasVotedToday,
|
|
promptText: promptText)
|
|
|
|
let midNightEntry = SimpleEntry(date: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date())!,
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: nil,
|
|
hasSubscription: hasSubscription,
|
|
hasVotedToday: hasVotedToday,
|
|
promptText: promptText)
|
|
|
|
let date = Calendar.current.date(byAdding: .second, value: 10, to: Date())!
|
|
let timeline = Timeline(entries: [entry, midNightEntry], policy: .after(date))
|
|
completion(timeline)
|
|
}
|
|
|
|
@MainActor
|
|
private func checkSubscriptionAndVoteStatus() -> (hasSubscription: Bool, hasVotedToday: Bool, promptText: String) {
|
|
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
|
|
|
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
|
let dayStart = Calendar.current.startOfDay(for: votingDate)
|
|
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart) ?? dayStart
|
|
|
|
let todayEntry = DataController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
|
let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder
|
|
|
|
let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body
|
|
|
|
return (hasSubscription, hasVotedToday, promptText)
|
|
}
|
|
}
|
|
|
|
struct SimpleEntry: TimelineEntry {
|
|
let date: Date
|
|
let configuration: ConfigurationIntent
|
|
let timeLineViews: [WatchTimelineView]?
|
|
let showStats: Bool
|
|
let hasSubscription: Bool
|
|
let hasVotedToday: Bool
|
|
let promptText: String
|
|
|
|
init(date: Date, configuration: ConfigurationIntent, timeLineViews: [WatchTimelineView]?, showStats: Bool = false, hasSubscription: Bool = false, hasVotedToday: Bool = true, promptText: String = "") {
|
|
self.date = date
|
|
self.configuration = configuration
|
|
self.timeLineViews = timeLineViews
|
|
self.showStats = showStats
|
|
self.hasSubscription = hasSubscription
|
|
self.hasVotedToday = hasVotedToday
|
|
self.promptText = promptText
|
|
}
|
|
}
|
|
|
|
/**********************************************************/
|
|
struct FeelsWidgetEntryView : View {
|
|
@Environment(\.sizeCategory) var sizeCategory
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var entry: Provider.Entry
|
|
|
|
private var showVotingForToday: Bool {
|
|
entry.hasSubscription && !entry.hasVotedToday
|
|
}
|
|
|
|
@ViewBuilder
|
|
var body: some View {
|
|
Group {
|
|
switch family {
|
|
case .systemSmall:
|
|
SmallWidgetView(entry: entry)
|
|
case .systemMedium:
|
|
MediumWidgetView(entry: entry)
|
|
case .systemLarge:
|
|
LargeWidgetView(entry: entry)
|
|
case .systemExtraLarge:
|
|
LargeWidgetView(entry: entry)
|
|
case .accessoryCircular, .accessoryRectangular, .accessoryInline:
|
|
SmallWidgetView(entry: entry)
|
|
@unknown default:
|
|
MediumWidgetView(entry: entry)
|
|
}
|
|
}
|
|
.containerBackground(showVotingForToday ? Color.clear : Color(UIColor.systemBackground), for: .widget)
|
|
}
|
|
}
|
|
|
|
struct SmallWidgetView: View {
|
|
var entry: Provider.Entry
|
|
var timeLineView = [WatchTimelineView]()
|
|
|
|
init(entry: Provider.Entry) {
|
|
self.entry = entry
|
|
let realData = TimeLineCreator.createViews(daysBack: 2)
|
|
// Check if we have any real mood data (not all missing)
|
|
let hasRealData = realData.contains { view in
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
return view.color != moodTint.color(forMood: .missing)
|
|
}
|
|
if let firstView = hasRealData ? realData.first : TimeLineCreator.createSampleViews(count: 1).first {
|
|
timeLineView = [firstView]
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
Color(UIColor.secondarySystemBackground)
|
|
HStack {
|
|
ForEach(self.timeLineView) { watchView in
|
|
EntryCard(timeLineView: watchView)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
|
|
.frame(minHeight: 0, maxHeight: 55)
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
struct MediumWidgetView: View {
|
|
var entry: Provider.Entry
|
|
var timeLineView = [WatchTimelineView]()
|
|
|
|
private var showVotingForToday: Bool {
|
|
entry.hasSubscription && !entry.hasVotedToday
|
|
}
|
|
|
|
init(entry: Provider.Entry) {
|
|
self.entry = entry
|
|
let realData = Array(TimeLineCreator.createViews(daysBack: 6).prefix(5))
|
|
// Check if we have any real mood data (not all missing)
|
|
let hasRealData = realData.contains { view in
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
return view.color != moodTint.color(forMood: .missing)
|
|
}
|
|
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 5)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Spacer()
|
|
|
|
if !showVotingForToday, let first = timeLineView.first, let last = timeLineView.last {
|
|
TimeHeaderView(startDate: first.date, endDate: last.date)
|
|
.frame(minWidth: 0, maxWidth: .infinity)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
TimeBodyView(group: timeLineView, showVotingForToday: showVotingForToday, promptText: entry.promptText)
|
|
.clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous))
|
|
.frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55)
|
|
.padding()
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
|
|
struct LargeWidgetView: View {
|
|
var entry: Provider.Entry
|
|
var timeLineView = [WatchTimelineView]()
|
|
|
|
private var showVotingForToday: Bool {
|
|
entry.hasSubscription && !entry.hasVotedToday
|
|
}
|
|
|
|
init(entry: Provider.Entry) {
|
|
self.entry = entry
|
|
let realData = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
|
// Check if we have any real mood data (not all missing)
|
|
let hasRealData = realData.contains { view in
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
return view.color != moodTint.color(forMood: .missing)
|
|
}
|
|
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 10)
|
|
}
|
|
|
|
var firstGroup: [WatchTimelineView] {
|
|
return Array(self.timeLineView.prefix(5))
|
|
}
|
|
|
|
var secondGroup: [WatchTimelineView] {
|
|
return Array(self.timeLineView.suffix(5))
|
|
}
|
|
|
|
var body: some View {
|
|
VStack {
|
|
Spacer()
|
|
|
|
// First row (includes today - may show voting)
|
|
VStack {
|
|
Spacer()
|
|
|
|
if !showVotingForToday, let first = firstGroup.first, let last = firstGroup.last {
|
|
TimeHeaderView(startDate: first.date, endDate: last.date)
|
|
.frame(minWidth: 0, maxWidth: .infinity)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
TimeBodyView(group: firstGroup, showVotingForToday: showVotingForToday, promptText: entry.promptText)
|
|
.clipShape(RoundedRectangle(cornerRadius: showVotingForToday ? 0 : 25, style: .continuous))
|
|
.frame(minHeight: 0, maxHeight: showVotingForToday ? 80 : 55)
|
|
.padding()
|
|
|
|
Spacer()
|
|
}
|
|
|
|
// Second row (older entries - never show voting)
|
|
VStack {
|
|
Spacer()
|
|
|
|
if let first = secondGroup.first, let last = secondGroup.last {
|
|
TimeHeaderView(startDate: first.date, endDate: last.date)
|
|
.frame(minWidth: 0, maxWidth: .infinity)
|
|
.multilineTextAlignment(.leading)
|
|
}
|
|
|
|
TimeBodyView(group: secondGroup, showVotingForToday: false)
|
|
.clipShape(RoundedRectangle(cornerRadius: 25, style: .continuous))
|
|
.frame(minHeight: 0, maxHeight: 55)
|
|
.padding()
|
|
|
|
Spacer()
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
}
|
|
}
|
|
/**********************************************************/
|
|
struct FeelsGraphicWidgetEntryView : View {
|
|
@Environment(\.sizeCategory) var sizeCategory
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var entry: Provider.Entry
|
|
|
|
@ViewBuilder
|
|
var body: some View {
|
|
SmallGraphicWidgetView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
}
|
|
|
|
struct SmallGraphicWidgetView: View {
|
|
var entry: Provider.Entry
|
|
var timeLineView: [WatchTimelineView]
|
|
|
|
init(entry: Provider.Entry) {
|
|
self.entry = entry
|
|
let realData = TimeLineCreator.createViews(daysBack: 2)
|
|
// Check if we have any real mood data (not all missing)
|
|
let hasRealData = realData.contains { view in
|
|
let moodTint: MoodTintable.Type = UserDefaultsStore.moodTintable()
|
|
return view.color != moodTint.color(forMood: .missing)
|
|
}
|
|
timeLineView = hasRealData ? realData : TimeLineCreator.createSampleViews(count: 2)
|
|
}
|
|
|
|
var body: some View {
|
|
if let first = timeLineView.first {
|
|
IconView(iconViewModel: IconViewModel(backgroundImage: first.graphic,
|
|
bgColor: first.color,
|
|
bgOverlayColor: first.secondaryColor,
|
|
centerImage: first.graphic,
|
|
innerColor: first.color))
|
|
} else {
|
|
IconView(iconViewModel: IconViewModel.great)
|
|
}
|
|
}
|
|
}
|
|
/**********************************************************/
|
|
struct FeelsIconWidgetEntryView : View {
|
|
@Environment(\.sizeCategory) var sizeCategory
|
|
@Environment(\.widgetFamily) var family
|
|
|
|
var entry: Provider.Entry
|
|
|
|
@ViewBuilder
|
|
var body: some View {
|
|
SmallIconView(entry: entry)
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
}
|
|
|
|
struct SmallIconView: View {
|
|
var entry: Provider.Entry
|
|
|
|
var body: some View {
|
|
GeometryReader { geo in
|
|
if let inUseWidget = UserDefaultsStore.getCustomWidgets().first(where: {
|
|
$0.inUse == true
|
|
}) {
|
|
CustomWidgetView(customWidgetModel: inUseWidget)
|
|
.frame(width: geo.size.width, height: geo.size.height)
|
|
} else {
|
|
CustomWidgetView(customWidgetModel: CustomWidgetModel.randomWidget)
|
|
.frame(width: geo.size.width, height: geo.size.height)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**********************************************************/
|
|
struct TimeHeaderView: View {
|
|
let startDate: Date
|
|
let endDate: Date
|
|
|
|
var formatter: DateFormatter {
|
|
let dateFormatter = DateFormatter()
|
|
dateFormatter.dateStyle = .medium
|
|
return dateFormatter
|
|
}
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(startDate, formatter: formatter)
|
|
.font(.system(.footnote))
|
|
Text(" - ")
|
|
.font(.system(.footnote))
|
|
Text(endDate, formatter: formatter)
|
|
.font(.system(.footnote))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct TimeBodyView: View {
|
|
let group: [WatchTimelineView]
|
|
var showVotingForToday: Bool = false
|
|
var promptText: String = ""
|
|
|
|
var body: some View {
|
|
if showVotingForToday {
|
|
// Show voting view without extra background container
|
|
InlineVotingView(promptText: promptText)
|
|
.padding()
|
|
} else {
|
|
ZStack {
|
|
Color(UIColor.secondarySystemBackground)
|
|
HStack(spacing: 4) {
|
|
ForEach(group) { watchView in
|
|
EntryCard(timeLineView: watchView)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Inline Voting View (compact mood buttons for timeline widget)
|
|
|
|
struct InlineVotingView: View {
|
|
let promptText: String
|
|
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
|
|
|
|
private var moodTint: MoodTintable.Type {
|
|
UserDefaultsStore.moodTintable()
|
|
}
|
|
|
|
private var moodImages: MoodImagable.Type {
|
|
UserDefaultsStore.moodMoodImagable()
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
Text(promptText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.7)
|
|
|
|
HStack(spacing: 8) {
|
|
ForEach(moods, id: \.rawValue) { mood in
|
|
Button(intent: VoteMoodIntent(mood: mood)) {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 44, height: 44)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EntryCard: View {
|
|
var timeLineView: WatchTimelineView
|
|
|
|
var body: some View {
|
|
timeLineView.image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 50, height: 50, alignment: .center)
|
|
.foregroundColor(timeLineView.color)
|
|
}
|
|
}
|
|
|
|
@main
|
|
struct FeelsBundle: WidgetBundle {
|
|
var body: some Widget {
|
|
FeelsWidget()
|
|
FeelsGraphicWidget()
|
|
FeelsIconWidget()
|
|
FeelsVoteWidget()
|
|
FeelsMoodControlWidget()
|
|
MoodStreakLiveActivity()
|
|
}
|
|
}
|
|
|
|
// MARK: - Control Center Widget
|
|
struct FeelsMoodControlWidget: ControlWidget {
|
|
var body: some ControlWidgetConfiguration {
|
|
StaticControlConfiguration(kind: "FeelsMoodControl") {
|
|
ControlWidgetButton(action: OpenFeelsIntent()) {
|
|
Label("Log Mood", systemImage: "face.smiling")
|
|
}
|
|
}
|
|
.displayName("Log Mood")
|
|
.description("Open Feels to log your mood")
|
|
}
|
|
}
|
|
|
|
struct OpenFeelsIntent: AppIntent {
|
|
static var title: LocalizedStringResource = "Open Feels"
|
|
static var description = IntentDescription("Open the Feels app to log your mood")
|
|
static var openAppWhenRun: Bool = true
|
|
|
|
func perform() async throws -> some IntentResult {
|
|
return .result()
|
|
}
|
|
}
|
|
|
|
struct FeelsWidget: Widget {
|
|
let kind: String = "FeelsWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
IntentConfiguration(kind: kind,
|
|
intent: ConfigurationIntent.self,
|
|
provider: Provider()) { entry in
|
|
FeelsWidgetEntryView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Feels")
|
|
.description("")
|
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
|
}
|
|
}
|
|
|
|
struct FeelsIconWidget: Widget {
|
|
let kind: String = "FeelsIconWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
IntentConfiguration(kind: kind,
|
|
intent: ConfigurationIntent.self,
|
|
provider: Provider()) { entry in
|
|
FeelsIconWidgetEntryView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Feels Icon")
|
|
.description("")
|
|
.supportedFamilies([.systemSmall])
|
|
}
|
|
}
|
|
|
|
struct FeelsGraphicWidget: Widget {
|
|
let kind: String = "FeelsGraphicWidget"
|
|
|
|
var body: some WidgetConfiguration {
|
|
IntentConfiguration(kind: kind,
|
|
intent: ConfigurationIntent.self,
|
|
provider: Provider()) { entry in
|
|
FeelsGraphicWidgetEntryView(entry: entry)
|
|
}
|
|
.configurationDisplayName("Mood Graphic")
|
|
.description("")
|
|
.supportedFamilies([.systemSmall])
|
|
}
|
|
}
|
|
|
|
struct FeelsWidget_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
Group {
|
|
FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(),
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great),
|
|
graphic: HandEmojiMoodImages.icon(forMood: .great),
|
|
date: Date(),
|
|
color: MoodTints.Neon.color(forMood: .great),
|
|
|
|
secondaryColor: .white),
|
|
WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .great),
|
|
graphic: HandEmojiMoodImages.icon(forMood: .great),
|
|
date: Date(),
|
|
color: MoodTints.Neon.color(forMood: .great),
|
|
|
|
secondaryColor: .white)]))
|
|
.previewContext(WidgetPreviewContext(family: .systemSmall))
|
|
|
|
FeelsGraphicWidgetEntryView(entry: SimpleEntry(date: Date(),
|
|
configuration: ConfigurationIntent(),
|
|
timeLineViews: [WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible),
|
|
graphic: HandEmojiMoodImages.icon(forMood: .horrible),
|
|
date: Date(),
|
|
color: MoodTints.Neon.color(forMood: .horrible),
|
|
|
|
secondaryColor: .white),
|
|
WatchTimelineView(image: HandEmojiMoodImages.icon(forMood: .horrible),
|
|
graphic: HandEmojiMoodImages.icon(forMood: .horrible),
|
|
date: Date(),
|
|
color: MoodTints.Neon.color(forMood: .horrible),
|
|
|
|
secondaryColor: .white)]))
|
|
.previewContext(WidgetPreviewContext(family: .systemSmall))
|
|
|
|
// FeelsWidgetEntryView(entry: SimpleEntry(date: Date(),
|
|
// configuration: ConfigurationIntent(),
|
|
// timeLineViews: FeelsWidget_Previews.data))
|
|
// .previewContext(WidgetPreviewContext(family: .systemMedium))
|
|
// .environment(\.sizeCategory, .medium)
|
|
//
|
|
// FeelsWidgetEntryView(entry: SimpleEntry(date: Date(),
|
|
// configuration: ConfigurationIntent(),
|
|
// timeLineViews: FeelsWidget_Previews.data))
|
|
// .previewContext(WidgetPreviewContext(family: .systemLarge))
|
|
// .environment(\.sizeCategory, .large)
|
|
}
|
|
}
|
|
}
|