Files
Reflect/FeelsWidget/FeelsVoteWidget.swift
Trey t f2c510de50 Refactor StoreKit 2 subscription system and add interactive vote widget
## StoreKit 2 Refactor
- Rewrote IAPManager with clean enum-based state model (SubscriptionState)
- Added native SubscriptionStoreView for iOS 17+ purchase UI
- Subscription status now checked on every app launch
- Synced subscription status to UserDefaults for widget access
- Simplified PurchaseButtonView and IAPWarningView
- Removed unused StatusInfoView

## Interactive Vote Widget
- New FeelsVoteWidget with App Intents for mood voting
- Subscribers can vote directly from widget, shows stats after voting
- Non-subscribers see "Tap to subscribe" which opens subscription store
- Added feels:// URL scheme for deep linking

## Firebase Removal
- Commented out Firebase imports and initialization
- EventLogger now prints to console in DEBUG mode only

## Other Changes
- Added fallback for Core Data when App Group unavailable
- Added new localization strings for subscription UI
- Updated entitlements and Info.plist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-09 23:07:16 -06:00

332 lines
11 KiB
Swift

//
// FeelsVoteWidget.swift
// FeelsWidget
//
// Interactive widget for mood voting (iOS 17+)
//
import WidgetKit
import SwiftUI
import AppIntents
// MARK: - App Intent for Mood Voting
struct VoteMoodIntent: AppIntent {
static var title: LocalizedStringResource = "Vote Mood"
static var description = IntentDescription("Record your mood for today")
@Parameter(title: "Mood")
var moodValue: Int
init() {
self.moodValue = 2
}
init(mood: Mood) {
self.moodValue = mood.rawValue
}
func perform() async throws -> some IntentResult {
let mood = Mood(rawValue: moodValue) ?? .average
let votingDate = ShowBasedOnVoteLogics.getCurrentVotingDate(onboardingData: UserDefaultsStore.getOnboarding())
// Add mood entry
PersistenceController.shared.add(mood: mood, forDate: votingDate, entryType: .widget)
// Store last voted date
let dateString = ISO8601DateFormatter().string(from: Calendar.current.startOfDay(for: votingDate))
GroupUserDefaults.groupDefaults.set(dateString, forKey: UserDefaultsStore.Keys.lastVotedDate.rawValue)
// Reload widget timeline
WidgetCenter.shared.reloadTimelines(ofKind: "FeelsVoteWidget")
return .result()
}
}
// MARK: - Vote Widget Provider
struct VoteWidgetProvider: TimelineProvider {
func placeholder(in context: Context) -> VoteWidgetEntry {
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: false, todaysMood: nil, stats: nil)
}
func getSnapshot(in context: Context, completion: @escaping (VoteWidgetEntry) -> Void) {
let entry = createEntry()
completion(entry)
}
func getTimeline(in context: Context, completion: @escaping (Timeline<VoteWidgetEntry>) -> Void) {
let entry = createEntry()
// Refresh at midnight
let midnight = Calendar.current.startOfDay(for: Calendar.current.date(byAdding: .day, value: 1, to: Date())!)
let timeline = Timeline(entries: [entry], policy: .after(midnight))
completion(timeline)
}
private func createEntry() -> VoteWidgetEntry {
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)!
// Check if user has voted today
let todayEntry = PersistenceController.shared.getData(startDate: dayStart, endDate: dayEnd, includedDays: []).first
let hasVotedToday = todayEntry != nil && todayEntry?.mood != .missing && todayEntry?.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 = PersistenceController.shared.getAllData()
let validEntries = allEntries.filter { $0.mood != .missing && $0.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)
}
}
return VoteWidgetEntry(
date: Date(),
hasSubscription: hasSubscription,
hasVotedToday: hasVotedToday,
todaysMood: todaysMood,
stats: stats
)
}
}
// 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?
}
// MARK: - Widget Views
struct FeelsVoteWidgetEntryView: View {
@Environment(\.widgetFamily) var family
var entry: VoteWidgetProvider.Entry
var body: some View {
Group {
if entry.hasSubscription {
if entry.hasVotedToday {
// Show stats after voting
VotedStatsView(entry: entry)
} else {
// Show voting buttons
VotingView(family: family)
}
} else {
// Non-subscriber view - tap to open app
NonSubscriberView()
}
}
.containerBackground(.fill.tertiary, for: .widget)
}
}
// MARK: - Voting View (for subscribers who haven't voted)
struct VotingView: View {
let family: WidgetFamily
let moods: [Mood] = [.horrible, .bad, .average, .good, .great]
var body: some View {
VStack(spacing: 8) {
Text("How are you feeling?")
.font(.headline)
.foregroundStyle(.primary)
if family == .systemSmall {
// Compact layout for small widget
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ForEach(moods, id: \.rawValue) { mood in
MoodButton(mood: mood, isCompact: true)
}
}
} else {
// Horizontal layout for medium/large
HStack(spacing: 12) {
ForEach(moods, id: \.rawValue) { mood in
MoodButton(mood: mood, isCompact: false)
}
}
}
}
.padding()
}
}
struct MoodButton: View {
let mood: Mood
let isCompact: Bool
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
var body: some View {
Button(intent: VoteMoodIntent(mood: mood)) {
VStack(spacing: 4) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: isCompact ? 28 : 36, height: isCompact ? 28 : 36)
.foregroundColor(moodTint.color(forMood: mood))
if !isCompact {
Text(mood.strValue)
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
.buttonStyle(.plain)
}
}
// MARK: - Voted Stats View (shown after voting)
struct VotedStatsView: View {
let entry: VoteWidgetEntry
private var moodTint: MoodTintable.Type {
UserDefaultsStore.moodTintable()
}
private var moodImages: MoodImagable.Type {
UserDefaultsStore.moodMoodImagable()
}
var body: some View {
VStack(spacing: 12) {
// Today's mood
if let mood = entry.todaysMood {
HStack(spacing: 8) {
moodImages.icon(forMood: mood)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 32, height: 32)
.foregroundColor(moodTint.color(forMood: mood))
VStack(alignment: .leading, spacing: 2) {
Text("Today")
.font(.caption)
.foregroundStyle(.secondary)
Text(mood.strValue)
.font(.headline)
.foregroundColor(moodTint.color(forMood: mood))
}
Spacer()
}
}
// Stats
if let stats = entry.stats {
Divider()
VStack(spacing: 4) {
Text("\(stats.totalEntries) entries")
.font(.caption)
.foregroundStyle(.secondary)
HStack(spacing: 4) {
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, CGFloat(percentage) * 0.8))
}
}
}
.frame(height: 8)
}
}
}
.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(.headline)
.foregroundStyle(.primary)
Text("Tap to subscribe")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
// 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)
VoteWidgetEntry(date: Date(), hasSubscription: true, hasVotedToday: true, todaysMood: .great, stats: MoodStats(totalEntries: 30, moodCounts: [.great: 10, .good: 12, .average: 5, .bad: 2, .horrible: 1]))
VoteWidgetEntry(date: Date(), hasSubscription: false, hasVotedToday: false, todaysMood: nil, stats: nil)
}