## 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>
332 lines
11 KiB
Swift
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)
|
|
}
|