- Add VoiceOver labels and hints to all voting layouts, settings, widgets, onboarding screens, and entry cells - Add Reduce Motion support to button animations throughout the app - Ensure 44x44pt minimum touch targets on widget mood buttons - Enhance AccessibilityHelpers with Dynamic Type support, ScaledValue wrapper, and VoiceOver detection utilities - Gate premium features (Insights, Month/Year views) behind subscription - Update widgets to show subscription prompts for non-subscribers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
461 lines
17 KiB
Swift
461 lines
17 KiB
Swift
//
|
|
// FeelsVoteWidget.swift
|
|
// FeelsWidget
|
|
//
|
|
// 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
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
struct FeelsVoteWidgetEntryView: View {
|
|
@Environment(\.widgetFamily) var family
|
|
var entry: VoteWidgetProvider.Entry
|
|
|
|
var body: some View {
|
|
Group {
|
|
if entry.hasVotedToday {
|
|
// Already voted today - show stats (regardless of subscription status)
|
|
VotedStatsView(entry: entry)
|
|
} else {
|
|
// Not voted yet - show voting buttons
|
|
// If subscribed/in trial: buttons record votes
|
|
// If trial expired: buttons open app
|
|
VotingView(family: family, promptText: entry.promptText, hasSubscription: entry.hasSubscription)
|
|
}
|
|
}
|
|
.containerBackground(.fill.tertiary, for: .widget)
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
Text(hasSubscription ? promptText : "Subscribe to track your mood")
|
|
.font(.headline)
|
|
.foregroundStyle(.primary)
|
|
.multilineTextAlignment(.center)
|
|
.lineLimit(2)
|
|
.minimumScaleFactor(0.8)
|
|
.padding(.bottom, 20)
|
|
|
|
HStack(spacing: 16) {
|
|
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
|
|
moodButton(for: mood, size: 44)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func moodButton(for mood: Mood, size: CGFloat) -> some View {
|
|
// Ensure minimum 44x44 touch target for accessibility
|
|
let touchSize = max(size, 44)
|
|
|
|
if hasSubscription {
|
|
// Active subscription: vote normally
|
|
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 {
|
|
// Trial expired: open app to subscribe
|
|
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"))
|
|
}
|
|
}
|
|
|
|
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 {
|
|
@Environment(\.widgetFamily) var family
|
|
let entry: VoteWidgetEntry
|
|
|
|
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: Centered mood with checkmark
|
|
private var smallLayout: some View {
|
|
VStack(spacing: 8) {
|
|
if let mood = entry.todaysMood {
|
|
// Large centered mood icon
|
|
ZStack(alignment: .bottomTrailing) {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 56, height: 56)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
|
|
// Checkmark badge
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.green)
|
|
.background(Circle().fill(.white).frame(width: 14, height: 14))
|
|
.offset(x: 4, y: 4)
|
|
}
|
|
|
|
Text("Logged!")
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let stats = entry.stats {
|
|
Text("\(stats.totalEntries) day streak")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.accessibilityElement(children: .combine)
|
|
.accessibilityLabel(String(localized: "Mood logged: \(entry.todaysMood?.strValue ?? "")"))
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.padding(12)
|
|
}
|
|
|
|
// MARK: - Medium: Mood + stats bar
|
|
private var mediumLayout: some View {
|
|
HStack(spacing: 20) {
|
|
if let mood = entry.todaysMood {
|
|
// Left: Mood display
|
|
VStack(spacing: 6) {
|
|
moodImages.icon(forMood: mood)
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 48, height: 48)
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
|
|
Text(mood.widgetDisplayName)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(moodTint.color(forMood: mood))
|
|
|
|
Text("Today")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
// Right: Stats
|
|
if let stats = entry.stats {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("\(stats.totalEntries) entries")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.primary)
|
|
|
|
// Mini mood breakdown
|
|
HStack(spacing: 6) {
|
|
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
|
|
let count = stats.moodCounts[mood, default: 0]
|
|
if count > 0 {
|
|
HStack(spacing: 2) {
|
|
Circle()
|
|
.fill(moodTint.color(forMood: mood))
|
|
.frame(width: 8, height: 8)
|
|
Text("\(count)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Progress bar
|
|
GeometryReader { geo in
|
|
HStack(spacing: 1) {
|
|
ForEach([Mood.great, .good, .average, .bad, .horrible], id: \.rawValue) { mood in
|
|
let percentage = stats.percentage(for: mood)
|
|
if percentage > 0 {
|
|
RoundedRectangle(cornerRadius: 2)
|
|
.fill(moodTint.color(forMood: mood))
|
|
.frame(width: max(4, geo.size.width * CGFloat(percentage) / 100))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// MARK: - Non-Subscriber View
|
|
|
|
struct NonSubscriberView: View {
|
|
var body: some View {
|
|
Link(destination: URL(string: "feels://subscribe")!) {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "heart.fill")
|
|
.font(.largeTitle)
|
|
.foregroundStyle(.pink)
|
|
|
|
Text("Track Your Mood")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
.minimumScaleFactor(0.8)
|
|
|
|
Text("Tap to subscribe")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(12)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
.accessibilityLabel(String(localized: "Track Your Mood"))
|
|
.accessibilityHint(String(localized: "Tap to open app and subscribe"))
|
|
}
|
|
}
|
|
|
|
// 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
|
|
|
|
#Preview(as: .systemSmall) {
|
|
FeelsVoteWidget()
|
|
} timeline: {
|
|
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "How are you feeling today?")
|
|
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]), promptText: "")
|
|
VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil, promptText: "")
|
|
}
|