Refactor widgets into separate focused files
Split the two large widget files (~2000 lines combined) into 10 focused files: - WidgetBundle.swift: Main @main bundle registration - WidgetModels.swift: Shared data models (WatchTimelineView, SimpleEntry, etc.) - WidgetProviders.swift: Timeline providers and TimeLineCreator - WidgetSharedViews.swift: Shared voting views - FeelsTimelineWidget.swift: Timeline widget (small/medium/large) - FeelsVoteWidget.swift: Vote widget with stats views - FeelsIconWidget.swift: Custom icon widget - FeelsGraphicWidget.swift: Graphic mood widget - FeelsMoodControlWidget.swift: Control Center widget - FeelsLiveActivity.swift: Live Activity with proper previews Preserves real-time update architecture (VoteMoodIntent, WidgetCenter, WidgetDataProvider patterns). Adds proper Live Activity preview support with sample content states. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
94
FeelsWidget2/FeelsGraphicWidget.swift
Normal file
94
FeelsWidget2/FeelsGraphicWidget.swift
Normal file
@@ -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: ""
|
||||
)
|
||||
}
|
||||
76
FeelsWidget2/FeelsIconWidget.swift
Normal file
76
FeelsWidget2/FeelsIconWidget.swift
Normal file
@@ -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: ""
|
||||
)
|
||||
}
|
||||
265
FeelsWidget2/FeelsLiveActivity.swift
Normal file
265
FeelsWidget2/FeelsLiveActivity.swift
Normal file
@@ -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<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))
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
36
FeelsWidget2/FeelsMoodControlWidget.swift
Normal file
36
FeelsWidget2/FeelsMoodControlWidget.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
578
FeelsWidget2/FeelsTimelineWidget.swift
Normal file
578
FeelsWidget2/FeelsTimelineWidget.swift
Normal file
@@ -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..<count).map { index in
|
||||
let mood = moods[(startIndex + index) % moods.count]
|
||||
return WatchTimelineView(
|
||||
image: EmojiMoodImages.icon(forMood: mood),
|
||||
graphic: EmojiMoodImages.icon(forMood: mood),
|
||||
date: Calendar.current.date(byAdding: .day, value: -index, to: Date())!,
|
||||
color: MoodTints.Default.color(forMood: mood),
|
||||
secondaryColor: MoodTints.Default.secondary(forMood: mood),
|
||||
mood: mood
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func sampleEntry(timelineCount: Int = 5, hasVotedToday: Bool = true, hasSubscription: Bool = true, startMood: Mood = .great) -> 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)
|
||||
}
|
||||
@@ -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<VoteWidgetEntry>) -> 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 {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
21
FeelsWidget2/WidgetBundle.swift
Normal file
21
FeelsWidget2/WidgetBundle.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
76
FeelsWidget2/WidgetModels.swift
Normal file
76
FeelsWidget2/WidgetModels.swift
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
264
FeelsWidget2/WidgetProviders.swift
Normal file
264
FeelsWidget2/WidgetProviders.swift
Normal file
@@ -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..<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),
|
||||
mood: mood
|
||||
))
|
||||
}
|
||||
|
||||
return timeLineView
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Widget Provider
|
||||
|
||||
struct Provider: @preconcurrency IntentTimelineProvider {
|
||||
typealias Entry = SimpleEntry
|
||||
typealias Intent = ConfigurationIntent
|
||||
|
||||
let timeLineCreator = TimeLineCreator()
|
||||
|
||||
func placeholder(in context: Context) -> SimpleEntry {
|
||||
return SimpleEntry(date: Date(),
|
||||
configuration: ConfigurationIntent(),
|
||||
timeLineViews: TimeLineCreator.createSampleViews(count: 10))
|
||||
}
|
||||
|
||||
@MainActor func getSnapshot(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
||||
// Use sample data for widget picker preview, real data otherwise
|
||||
let timeLineViews: [WatchTimelineView]
|
||||
if context.isPreview {
|
||||
timeLineViews = TimeLineCreator.createSampleViews(count: 10)
|
||||
} else {
|
||||
timeLineViews = Array(TimeLineCreator.createViews(daysBack: 11).prefix(10))
|
||||
}
|
||||
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
|
||||
let entry = SimpleEntry(date: Date(),
|
||||
configuration: ConfigurationIntent(),
|
||||
timeLineViews: timeLineViews,
|
||||
hasSubscription: hasSubscription,
|
||||
hasVotedToday: hasVotedToday,
|
||||
promptText: promptText)
|
||||
completion(entry)
|
||||
}
|
||||
|
||||
@MainActor func getTimeline(for configuration: ConfigurationIntent, in context: Context, completion: @escaping (Timeline<Entry>) -> ()) {
|
||||
let (hasSubscription, hasVotedToday, promptText) = checkSubscriptionAndVoteStatus()
|
||||
let entry = SimpleEntry(date: Calendar.current.date(byAdding: .second, value: 15, to: Date())!,
|
||||
configuration: ConfigurationIntent(),
|
||||
timeLineViews: nil,
|
||||
hasSubscription: hasSubscription,
|
||||
hasVotedToday: hasVotedToday,
|
||||
promptText: promptText)
|
||||
|
||||
let midNightEntry = SimpleEntry(date: Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: Date())!,
|
||||
configuration: ConfigurationIntent(),
|
||||
timeLineViews: nil,
|
||||
hasSubscription: hasSubscription,
|
||||
hasVotedToday: hasVotedToday,
|
||||
promptText: promptText)
|
||||
|
||||
let date = Calendar.current.date(byAdding: .second, value: 10, to: Date())!
|
||||
let timeline = Timeline(entries: [entry, midNightEntry], policy: .after(date))
|
||||
completion(timeline)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func checkSubscriptionAndVoteStatus() -> (hasSubscription: Bool, hasVotedToday: Bool, promptText: String) {
|
||||
let hasSubscription = GroupUserDefaults.groupDefaults.bool(forKey: UserDefaultsStore.Keys.hasActiveSubscription.rawValue)
|
||||
|
||||
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
|
||||
let dayStart = Calendar.current.startOfDay(for: votingDate)
|
||||
let dayEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: dayStart) ?? dayStart
|
||||
|
||||
// Use WidgetDataProvider for isolated widget data access
|
||||
let todayEntry = WidgetDataProvider.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
|
||||
let hasVotedToday = todayEntry != nil && todayEntry?.mood != Mood.missing && todayEntry?.mood != Mood.placeholder
|
||||
|
||||
let promptText = UserDefaultsStore.personalityPackable().randomPushNotificationStrings().body
|
||||
|
||||
return (hasSubscription, hasVotedToday, promptText)
|
||||
}
|
||||
}
|
||||
|
||||
// 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<VoteWidgetEntry>) -> 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
|
||||
)
|
||||
}
|
||||
}
|
||||
289
FeelsWidget2/WidgetSharedViews.swift
Normal file
289
FeelsWidget2/WidgetSharedViews.swift
Normal file
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user