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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user