Add AI-powered insights using Apple Foundation Models
- Replace static insights with on-device AI generation via FoundationModels framework - Add @Generable AIInsight model for structured LLM output - Create FoundationModelsInsightService with session-per-request for concurrent generation - Add MoodDataSummarizer to prepare mood data for AI analysis - Implement loading states with skeleton UI and pull-to-refresh - Add AI availability badge and error handling - Support default (supportive) and rude (sarcastic) personality modes - Optimize prompts to fit within 4096 token context limit - Bump iOS deployment target to 26.0 for Foundation Models support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -657,7 +657,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
@@ -693,7 +693,7 @@
|
|||||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 18.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
|
|||||||
68
Shared/Models/AIInsight.swift
Normal file
68
Shared/Models/AIInsight.swift
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
//
|
||||||
|
// AIInsight.swift
|
||||||
|
// Feels
|
||||||
|
//
|
||||||
|
// Created by Claude Code on 12/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// Represents a single AI-generated insight using @Generable for structured LLM output
|
||||||
|
@Generable
|
||||||
|
struct AIInsight: Equatable {
|
||||||
|
@Guide(description: "A brief, engaging title for the insight (3-6 words)")
|
||||||
|
var title: String
|
||||||
|
|
||||||
|
@Guide(description: "A detailed, personalized description of the insight (1-2 sentences)")
|
||||||
|
var description: String
|
||||||
|
|
||||||
|
@Guide(description: "The type of insight: pattern (observed trend), advice (recommendation), prediction (future forecast), achievement (milestone), or trend (direction analysis)")
|
||||||
|
var insightType: String
|
||||||
|
|
||||||
|
@Guide(description: "The primary mood associated with this insight: great, good, average, bad, horrible, or none")
|
||||||
|
var associatedMood: String
|
||||||
|
|
||||||
|
@Guide(description: "SF Symbol name for the insight icon (e.g., star.fill, chart.line.uptrend.xyaxis, heart.fill)")
|
||||||
|
var iconName: String
|
||||||
|
|
||||||
|
@Guide(description: "Sentiment of the insight: positive, neutral, or negative")
|
||||||
|
var sentiment: String
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container for period-specific insights - the LLM generates this structure
|
||||||
|
@Generable
|
||||||
|
struct AIInsightsResponse: Equatable {
|
||||||
|
@Guide(description: "Array of exactly 5 diverse insights covering patterns, advice, and predictions")
|
||||||
|
var insights: [AIInsight]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Conversion to App's Insight Model
|
||||||
|
|
||||||
|
extension AIInsight {
|
||||||
|
/// Converts AI-generated insight to the app's existing Insight model
|
||||||
|
func toInsight() -> Insight {
|
||||||
|
let mood: Mood? = switch associatedMood.lowercased() {
|
||||||
|
case "great": .great
|
||||||
|
case "good": .good
|
||||||
|
case "average": .average
|
||||||
|
case "bad": .bad
|
||||||
|
case "horrible": .horrible
|
||||||
|
default: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return Insight(
|
||||||
|
icon: iconName,
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
mood: mood
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AIInsightsResponse {
|
||||||
|
/// Converts all AI insights to app's Insight models
|
||||||
|
func toInsights() -> [Insight] {
|
||||||
|
insights.map { $0.toInsight() }
|
||||||
|
}
|
||||||
|
}
|
||||||
219
Shared/Services/FoundationModelsInsightService.swift
Normal file
219
Shared/Services/FoundationModelsInsightService.swift
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
//
|
||||||
|
// FoundationModelsInsightService.swift
|
||||||
|
// Feels
|
||||||
|
//
|
||||||
|
// Created by Claude Code on 12/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// Error types for insight generation
|
||||||
|
enum InsightGenerationError: Error, LocalizedError {
|
||||||
|
case modelUnavailable(reason: String)
|
||||||
|
case insufficientData
|
||||||
|
case generationFailed(underlying: Error)
|
||||||
|
case invalidResponse
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .modelUnavailable(let reason):
|
||||||
|
return "AI insights unavailable: \(reason)"
|
||||||
|
case .insufficientData:
|
||||||
|
return "Not enough mood data to generate insights"
|
||||||
|
case .generationFailed(let error):
|
||||||
|
return "Failed to generate insights: \(error.localizedDescription)"
|
||||||
|
case .invalidResponse:
|
||||||
|
return "Unable to parse AI response"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service responsible for generating AI-powered mood insights using Apple's Foundation Models
|
||||||
|
@MainActor
|
||||||
|
class FoundationModelsInsightService: ObservableObject {
|
||||||
|
|
||||||
|
// MARK: - Published State
|
||||||
|
|
||||||
|
@Published private(set) var isAvailable: Bool = false
|
||||||
|
@Published private(set) var isGenerating: Bool = false
|
||||||
|
@Published private(set) var lastError: InsightGenerationError?
|
||||||
|
|
||||||
|
// MARK: - Dependencies
|
||||||
|
|
||||||
|
private let summarizer = MoodDataSummarizer()
|
||||||
|
|
||||||
|
// MARK: - Cache
|
||||||
|
|
||||||
|
private var cachedInsights: [String: (insights: [Insight], timestamp: Date)] = [:]
|
||||||
|
private let cacheValidityDuration: TimeInterval = 3600 // 1 hour
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
init() {
|
||||||
|
checkAvailability()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if Foundation Models is available on this device
|
||||||
|
func checkAvailability() {
|
||||||
|
let model = SystemLanguageModel.default
|
||||||
|
switch model.availability {
|
||||||
|
case .available:
|
||||||
|
isAvailable = true
|
||||||
|
case .unavailable(let reason):
|
||||||
|
isAvailable = false
|
||||||
|
lastError = .modelUnavailable(reason: describeUnavailability(reason))
|
||||||
|
@unknown default:
|
||||||
|
isAvailable = false
|
||||||
|
lastError = .modelUnavailable(reason: "Unknown availability status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func describeUnavailability(_ reason: SystemLanguageModel.Availability.UnavailableReason) -> String {
|
||||||
|
switch reason {
|
||||||
|
case .deviceNotEligible:
|
||||||
|
return "This device doesn't support Apple Intelligence"
|
||||||
|
case .appleIntelligenceNotEnabled:
|
||||||
|
return "Apple Intelligence is not enabled in Settings"
|
||||||
|
case .modelNotReady:
|
||||||
|
return "The AI model is still downloading"
|
||||||
|
@unknown default:
|
||||||
|
return "AI features are not available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new session for each request to allow concurrent generation
|
||||||
|
private func createSession() -> LanguageModelSession {
|
||||||
|
LanguageModelSession(instructions: systemInstructions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - System Instructions
|
||||||
|
|
||||||
|
private var systemInstructions: String {
|
||||||
|
let personalityPack = UserDefaultsStore.personalityPackable()
|
||||||
|
|
||||||
|
switch personalityPack {
|
||||||
|
case .Default:
|
||||||
|
return defaultSystemInstructions
|
||||||
|
case .Rude:
|
||||||
|
return rudeSystemInstructions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var defaultSystemInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a supportive mood analyst for the Feels app. Analyze mood data and provide warm, actionable insights.
|
||||||
|
|
||||||
|
Style: Encouraging, empathetic, concise (1-2 sentences per insight). Reference specific data.
|
||||||
|
|
||||||
|
SF Symbols: star.fill, sun.max.fill, heart.fill, chart.line.uptrend.xyaxis, trophy.fill, calendar, leaf.fill, sparkles
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rudeSystemInstructions: String {
|
||||||
|
"""
|
||||||
|
You are a brutally honest, sarcastic mood analyst. Think: judgmental friend meets disappointed therapist.
|
||||||
|
|
||||||
|
Style: Backhanded compliments, dramatic disappointment, weaponize their own data against them. Use "Oh honey," "Congratulations," and "Shocking" sarcastically. Mock patterns, not pain.
|
||||||
|
|
||||||
|
Examples: "THREE whole good days? Trophy's in the mail." / "Mondays destroy you. Revolutionary." / "Your mood tanks Sundays. Almost like you hate your job."
|
||||||
|
|
||||||
|
SF Symbols: eye.fill, trophy.fill, flame.fill, exclamationmark.triangle.fill, theatermasks.fill, crown.fill
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Insight Generation
|
||||||
|
|
||||||
|
/// Generate AI-powered insights for the given mood entries
|
||||||
|
/// - Parameters:
|
||||||
|
/// - entries: Array of mood entries to analyze
|
||||||
|
/// - periodName: The time period name (e.g., "this month", "this year", "all time")
|
||||||
|
/// - count: Number of insights to generate (default 5)
|
||||||
|
/// - Returns: Array of Insight objects
|
||||||
|
func generateInsights(
|
||||||
|
for entries: [MoodEntryModel],
|
||||||
|
periodName: String,
|
||||||
|
count: Int = 5
|
||||||
|
) async throws -> [Insight] {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = cachedInsights[periodName],
|
||||||
|
Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration {
|
||||||
|
return cached.insights
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isAvailable else {
|
||||||
|
throw InsightGenerationError.modelUnavailable(reason: lastError?.localizedDescription ?? "Model not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new session for this request to allow concurrent generation
|
||||||
|
let session = createSession()
|
||||||
|
|
||||||
|
// Filter valid entries
|
||||||
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
|
||||||
|
guard !validEntries.isEmpty else {
|
||||||
|
throw InsightGenerationError.insufficientData
|
||||||
|
}
|
||||||
|
|
||||||
|
isGenerating = true
|
||||||
|
defer { isGenerating = false }
|
||||||
|
|
||||||
|
// Prepare data summary
|
||||||
|
let summary = summarizer.summarize(entries: validEntries, periodName: periodName)
|
||||||
|
let prompt = buildPrompt(from: summary, count: count)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: prompt,
|
||||||
|
generating: AIInsightsResponse.self
|
||||||
|
)
|
||||||
|
|
||||||
|
let insights = response.content.insights.map { $0.toInsight() }
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
cachedInsights[periodName] = (insights, Date())
|
||||||
|
|
||||||
|
return insights
|
||||||
|
} catch {
|
||||||
|
// Log detailed error for debugging
|
||||||
|
print("❌ AI Insight generation failed for '\(periodName)': \(error)")
|
||||||
|
print(" Error type: \(type(of: error))")
|
||||||
|
print(" Localized: \(error.localizedDescription)")
|
||||||
|
|
||||||
|
lastError = .generationFailed(underlying: error)
|
||||||
|
throw lastError!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt Construction
|
||||||
|
|
||||||
|
private func buildPrompt(from summary: MoodDataSummary, count: Int) -> String {
|
||||||
|
let dataSection = summarizer.toPromptString(summary)
|
||||||
|
|
||||||
|
return """
|
||||||
|
Analyze this mood data and generate \(count) insights:
|
||||||
|
|
||||||
|
\(dataSection)
|
||||||
|
|
||||||
|
Include: 1 pattern, 1 advice, 1 prediction, and other varied insights. Reference specific data points.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cache Management
|
||||||
|
|
||||||
|
/// Invalidate all cached insights
|
||||||
|
func invalidateCache() {
|
||||||
|
cachedInsights.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invalidate cached insights for a specific period
|
||||||
|
func invalidateCache(for periodName: String) {
|
||||||
|
cachedInsights.removeValue(forKey: periodName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if insights are cached for a period
|
||||||
|
func hasCachedInsights(for periodName: String) -> Bool {
|
||||||
|
guard let cached = cachedInsights[periodName] else { return false }
|
||||||
|
return Date().timeIntervalSince(cached.timestamp) < cacheValidityDuration
|
||||||
|
}
|
||||||
|
}
|
||||||
389
Shared/Services/MoodDataSummarizer.swift
Normal file
389
Shared/Services/MoodDataSummarizer.swift
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
//
|
||||||
|
// MoodDataSummarizer.swift
|
||||||
|
// Feels
|
||||||
|
//
|
||||||
|
// Created by Claude Code on 12/13/24.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Summary of mood data for a specific time period, formatted for AI consumption
|
||||||
|
struct MoodDataSummary {
|
||||||
|
let periodName: String
|
||||||
|
let totalEntries: Int
|
||||||
|
let dateRange: String
|
||||||
|
|
||||||
|
// Mood distribution
|
||||||
|
let moodCounts: [String: Int]
|
||||||
|
let moodPercentages: [String: Int]
|
||||||
|
let averageMoodScore: Double
|
||||||
|
|
||||||
|
// Temporal patterns
|
||||||
|
let weekdayAverages: [String: Double]
|
||||||
|
let weekendAverage: Double
|
||||||
|
let weekdayAverage: Double
|
||||||
|
let bestDayOfWeek: String
|
||||||
|
let worstDayOfWeek: String
|
||||||
|
|
||||||
|
// Trends
|
||||||
|
let recentTrend: String
|
||||||
|
let trendMagnitude: Double
|
||||||
|
|
||||||
|
// Streaks
|
||||||
|
let currentLoggingStreak: Int
|
||||||
|
let longestLoggingStreak: Int
|
||||||
|
let longestPositiveStreak: Int
|
||||||
|
let longestNegativeStreak: Int
|
||||||
|
|
||||||
|
// Variability
|
||||||
|
let moodSwingCount: Int
|
||||||
|
let moodStabilityScore: Double
|
||||||
|
|
||||||
|
// Recent context
|
||||||
|
let last7DaysAverage: Double
|
||||||
|
let last7DaysMoods: [String]
|
||||||
|
|
||||||
|
// Notable observations
|
||||||
|
let hasAllMoodTypes: Bool
|
||||||
|
let missingMoodTypes: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms raw MoodEntryModel data into AI-optimized summaries
|
||||||
|
class MoodDataSummarizer {
|
||||||
|
private let calendar = Calendar.current
|
||||||
|
private let dateFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
// MARK: - Main Summarization
|
||||||
|
|
||||||
|
func summarize(entries: [MoodEntryModel], periodName: String) -> MoodDataSummary {
|
||||||
|
let validEntries = entries.filter { ![.missing, .placeholder].contains($0.mood) }
|
||||||
|
|
||||||
|
guard !validEntries.isEmpty else {
|
||||||
|
return emptyDataSummary(periodName: periodName)
|
||||||
|
}
|
||||||
|
|
||||||
|
let sortedEntries = validEntries.sorted { $0.forDate < $1.forDate }
|
||||||
|
|
||||||
|
// Calculate all metrics
|
||||||
|
let moodDistribution = calculateMoodDistribution(entries: validEntries)
|
||||||
|
let temporalPatterns = calculateTemporalPatterns(entries: validEntries)
|
||||||
|
let trend = calculateTrend(entries: sortedEntries)
|
||||||
|
let streaks = calculateStreaks(entries: sortedEntries)
|
||||||
|
let variability = calculateVariability(entries: sortedEntries)
|
||||||
|
let recentContext = calculateRecentContext(entries: sortedEntries)
|
||||||
|
let moodTypes = calculateMoodTypes(entries: validEntries)
|
||||||
|
|
||||||
|
// Format date range
|
||||||
|
let dateRange = formatDateRange(entries: sortedEntries)
|
||||||
|
|
||||||
|
return MoodDataSummary(
|
||||||
|
periodName: periodName,
|
||||||
|
totalEntries: validEntries.count,
|
||||||
|
dateRange: dateRange,
|
||||||
|
moodCounts: moodDistribution.counts,
|
||||||
|
moodPercentages: moodDistribution.percentages,
|
||||||
|
averageMoodScore: moodDistribution.average,
|
||||||
|
weekdayAverages: temporalPatterns.weekdayAverages,
|
||||||
|
weekendAverage: temporalPatterns.weekendAverage,
|
||||||
|
weekdayAverage: temporalPatterns.weekdayAverage,
|
||||||
|
bestDayOfWeek: temporalPatterns.bestDay,
|
||||||
|
worstDayOfWeek: temporalPatterns.worstDay,
|
||||||
|
recentTrend: trend.direction,
|
||||||
|
trendMagnitude: trend.magnitude,
|
||||||
|
currentLoggingStreak: streaks.current,
|
||||||
|
longestLoggingStreak: streaks.longest,
|
||||||
|
longestPositiveStreak: streaks.longestPositive,
|
||||||
|
longestNegativeStreak: streaks.longestNegative,
|
||||||
|
moodSwingCount: variability.swingCount,
|
||||||
|
moodStabilityScore: variability.stabilityScore,
|
||||||
|
last7DaysAverage: recentContext.average,
|
||||||
|
last7DaysMoods: recentContext.moods,
|
||||||
|
hasAllMoodTypes: moodTypes.hasAll,
|
||||||
|
missingMoodTypes: moodTypes.missing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mood Distribution
|
||||||
|
|
||||||
|
private func calculateMoodDistribution(entries: [MoodEntryModel]) -> (counts: [String: Int], percentages: [String: Int], average: Double) {
|
||||||
|
var counts: [String: Int] = [:]
|
||||||
|
var totalScore = 0
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let moodName = entry.mood.widgetDisplayName.lowercased()
|
||||||
|
counts[moodName, default: 0] += 1
|
||||||
|
totalScore += Int(entry.moodValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
var percentages: [String: Int] = [:]
|
||||||
|
for (mood, count) in counts {
|
||||||
|
percentages[mood] = Int((Double(count) / Double(entries.count)) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
let average = Double(totalScore) / Double(entries.count)
|
||||||
|
|
||||||
|
return (counts, percentages, average)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Temporal Patterns
|
||||||
|
|
||||||
|
private func calculateTemporalPatterns(entries: [MoodEntryModel]) -> (weekdayAverages: [String: Double], weekendAverage: Double, weekdayAverage: Double, bestDay: String, worstDay: String) {
|
||||||
|
let weekdayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
|
||||||
|
var weekdayTotals: [Int: (total: Int, count: Int)] = [:]
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let weekday = Int(entry.weekDay)
|
||||||
|
let current = weekdayTotals[weekday, default: (0, 0)]
|
||||||
|
weekdayTotals[weekday] = (current.total + Int(entry.moodValue), current.count + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
var weekdayAverages: [String: Double] = [:]
|
||||||
|
var bestDay = "Monday"
|
||||||
|
var worstDay = "Monday"
|
||||||
|
var bestAvg = -1.0
|
||||||
|
var worstAvg = 5.0
|
||||||
|
|
||||||
|
for (weekday, data) in weekdayTotals {
|
||||||
|
let avg = Double(data.total) / Double(data.count)
|
||||||
|
let dayName = weekdayNames[weekday - 1]
|
||||||
|
weekdayAverages[dayName] = avg
|
||||||
|
|
||||||
|
if avg > bestAvg {
|
||||||
|
bestAvg = avg
|
||||||
|
bestDay = dayName
|
||||||
|
}
|
||||||
|
if avg < worstAvg {
|
||||||
|
worstAvg = avg
|
||||||
|
worstDay = dayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekend vs weekday
|
||||||
|
let weekendEntries = entries.filter { [1, 7].contains(Int($0.weekDay)) }
|
||||||
|
let weekdayEntries = entries.filter { ![1, 7].contains(Int($0.weekDay)) }
|
||||||
|
|
||||||
|
let weekendAvg = weekendEntries.isEmpty ? 0 : Double(weekendEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekendEntries.count)
|
||||||
|
let weekdayAvg = weekdayEntries.isEmpty ? 0 : Double(weekdayEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(weekdayEntries.count)
|
||||||
|
|
||||||
|
return (weekdayAverages, weekendAvg, weekdayAvg, bestDay, worstDay)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Trend Analysis
|
||||||
|
|
||||||
|
private func calculateTrend(entries: [MoodEntryModel]) -> (direction: String, magnitude: Double) {
|
||||||
|
guard entries.count >= 4 else {
|
||||||
|
return ("stable", 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let halfCount = entries.count / 2
|
||||||
|
let firstHalf = Array(entries.prefix(halfCount))
|
||||||
|
let secondHalf = Array(entries.suffix(halfCount))
|
||||||
|
|
||||||
|
let firstAvg = Double(firstHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(firstHalf.count)
|
||||||
|
let secondAvg = Double(secondHalf.reduce(0) { $0 + Int($1.moodValue) }) / Double(secondHalf.count)
|
||||||
|
|
||||||
|
let diff = secondAvg - firstAvg
|
||||||
|
|
||||||
|
let direction: String
|
||||||
|
if diff > 0.5 {
|
||||||
|
direction = "improving"
|
||||||
|
} else if diff < -0.5 {
|
||||||
|
direction = "declining"
|
||||||
|
} else {
|
||||||
|
direction = "stable"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (direction, abs(diff))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Streak Calculations
|
||||||
|
|
||||||
|
private func calculateStreaks(entries: [MoodEntryModel]) -> (current: Int, longest: Int, longestPositive: Int, longestNegative: Int) {
|
||||||
|
let sortedByDateDesc = entries.sorted { $0.forDate > $1.forDate }
|
||||||
|
|
||||||
|
// Current logging streak
|
||||||
|
var currentStreak = 0
|
||||||
|
let today = calendar.startOfDay(for: Date())
|
||||||
|
|
||||||
|
if let mostRecent = sortedByDateDesc.first?.forDate {
|
||||||
|
let yesterday = calendar.date(byAdding: .day, value: -1, to: today)!
|
||||||
|
|
||||||
|
if calendar.isDate(mostRecent, inSameDayAs: today) || calendar.isDate(mostRecent, inSameDayAs: yesterday) {
|
||||||
|
currentStreak = 1
|
||||||
|
var checkDate = calendar.date(byAdding: .day, value: -1, to: mostRecent)!
|
||||||
|
|
||||||
|
for entry in sortedByDateDesc.dropFirst() {
|
||||||
|
if calendar.isDate(entry.forDate, inSameDayAs: checkDate) {
|
||||||
|
currentStreak += 1
|
||||||
|
checkDate = calendar.date(byAdding: .day, value: -1, to: checkDate)!
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Longest logging streak
|
||||||
|
var longestStreak = 1
|
||||||
|
var tempStreak = 1
|
||||||
|
let sortedByDateAsc = entries.sorted { $0.forDate < $1.forDate }
|
||||||
|
|
||||||
|
for i in 1..<sortedByDateAsc.count {
|
||||||
|
let dayDiff = calendar.dateComponents([.day], from: sortedByDateAsc[i-1].forDate, to: sortedByDateAsc[i].forDate).day ?? 0
|
||||||
|
if dayDiff == 1 {
|
||||||
|
tempStreak += 1
|
||||||
|
longestStreak = max(longestStreak, tempStreak)
|
||||||
|
} else {
|
||||||
|
tempStreak = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Positive mood streak (good/great)
|
||||||
|
let longestPositive = calculateMoodStreak(entries: sortedByDateAsc, moods: [.good, .great])
|
||||||
|
|
||||||
|
// Negative mood streak (bad/horrible)
|
||||||
|
let longestNegative = calculateMoodStreak(entries: sortedByDateAsc, moods: [.bad, .horrible])
|
||||||
|
|
||||||
|
return (currentStreak, longestStreak, longestPositive, longestNegative)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func calculateMoodStreak(entries: [MoodEntryModel], moods: [Mood]) -> Int {
|
||||||
|
var longest = 0
|
||||||
|
var current = 0
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
if moods.contains(entry.mood) {
|
||||||
|
current += 1
|
||||||
|
longest = max(longest, current)
|
||||||
|
} else {
|
||||||
|
current = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return longest
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Variability
|
||||||
|
|
||||||
|
private func calculateVariability(entries: [MoodEntryModel]) -> (swingCount: Int, stabilityScore: Double) {
|
||||||
|
guard entries.count >= 2 else {
|
||||||
|
return (0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
var swings = 0
|
||||||
|
for i in 1..<entries.count {
|
||||||
|
let diff = abs(Int(entries[i].moodValue) - Int(entries[i-1].moodValue))
|
||||||
|
if diff >= 2 {
|
||||||
|
swings += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let swingRate = Double(swings) / Double(entries.count - 1)
|
||||||
|
let stabilityScore = 1.0 - min(swingRate, 1.0)
|
||||||
|
|
||||||
|
return (swings, stabilityScore)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recent Context
|
||||||
|
|
||||||
|
private func calculateRecentContext(entries: [MoodEntryModel]) -> (average: Double, moods: [String]) {
|
||||||
|
let recentEntries = entries.suffix(7)
|
||||||
|
|
||||||
|
guard !recentEntries.isEmpty else {
|
||||||
|
return (0, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
let average = Double(recentEntries.reduce(0) { $0 + Int($1.moodValue) }) / Double(recentEntries.count)
|
||||||
|
let moods = recentEntries.map { $0.mood.widgetDisplayName }
|
||||||
|
|
||||||
|
return (average, moods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Mood Types
|
||||||
|
|
||||||
|
private func calculateMoodTypes(entries: [MoodEntryModel]) -> (hasAll: Bool, missing: [String]) {
|
||||||
|
let allMoods: Set<Mood> = [.great, .good, .average, .bad, .horrible]
|
||||||
|
let presentMoods = Set(entries.map { $0.mood })
|
||||||
|
|
||||||
|
let missing = allMoods.subtracting(presentMoods).map { $0.widgetDisplayName }
|
||||||
|
let hasAll = missing.isEmpty
|
||||||
|
|
||||||
|
return (hasAll, missing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
|
||||||
|
private func formatDateRange(entries: [MoodEntryModel]) -> String {
|
||||||
|
guard let first = entries.first, let last = entries.last else {
|
||||||
|
return "No data"
|
||||||
|
}
|
||||||
|
|
||||||
|
let startDate = dateFormatter.string(from: first.forDate)
|
||||||
|
let endDate = dateFormatter.string(from: last.forDate)
|
||||||
|
|
||||||
|
return "\(startDate) - \(endDate)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func emptyDataSummary(periodName: String) -> MoodDataSummary {
|
||||||
|
MoodDataSummary(
|
||||||
|
periodName: periodName,
|
||||||
|
totalEntries: 0,
|
||||||
|
dateRange: "No data",
|
||||||
|
moodCounts: [:],
|
||||||
|
moodPercentages: [:],
|
||||||
|
averageMoodScore: 0,
|
||||||
|
weekdayAverages: [:],
|
||||||
|
weekendAverage: 0,
|
||||||
|
weekdayAverage: 0,
|
||||||
|
bestDayOfWeek: "N/A",
|
||||||
|
worstDayOfWeek: "N/A",
|
||||||
|
recentTrend: "stable",
|
||||||
|
trendMagnitude: 0,
|
||||||
|
currentLoggingStreak: 0,
|
||||||
|
longestLoggingStreak: 0,
|
||||||
|
longestPositiveStreak: 0,
|
||||||
|
longestNegativeStreak: 0,
|
||||||
|
moodSwingCount: 0,
|
||||||
|
moodStabilityScore: 1.0,
|
||||||
|
last7DaysAverage: 0,
|
||||||
|
last7DaysMoods: [],
|
||||||
|
hasAllMoodTypes: false,
|
||||||
|
missingMoodTypes: ["great", "good", "average", "bad", "horrible"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Prompt String Generation
|
||||||
|
|
||||||
|
/// Generates a concise prompt string optimized for Apple's 4096 token context limit
|
||||||
|
func toPromptString(_ summary: MoodDataSummary) -> String {
|
||||||
|
// Compact format to stay under token limit
|
||||||
|
var lines: [String] = []
|
||||||
|
|
||||||
|
lines.append("Period: \(summary.periodName), \(summary.totalEntries) entries, avg \(String(format: "%.1f", summary.averageMoodScore))/4")
|
||||||
|
|
||||||
|
// Mood distribution - compact
|
||||||
|
let moodDist = summary.moodPercentages.sorted { $0.key < $1.key }
|
||||||
|
.map { "\($0.key): \($0.value)%" }
|
||||||
|
.joined(separator: ", ")
|
||||||
|
lines.append("Moods: \(moodDist)")
|
||||||
|
|
||||||
|
// Day patterns - only best/worst
|
||||||
|
lines.append("Best day: \(summary.bestDayOfWeek), Worst: \(summary.worstDayOfWeek)")
|
||||||
|
lines.append("Weekend avg: \(String(format: "%.1f", summary.weekendAverage)), Weekday avg: \(String(format: "%.1f", summary.weekdayAverage))")
|
||||||
|
|
||||||
|
// Trends
|
||||||
|
lines.append("Trend: \(summary.recentTrend), Last 7 days avg: \(String(format: "%.1f", summary.last7DaysAverage))")
|
||||||
|
|
||||||
|
// Streaks - compact
|
||||||
|
lines.append("Streaks - Current: \(summary.currentLoggingStreak)d, Longest: \(summary.longestLoggingStreak)d, Best positive: \(summary.longestPositiveStreak)d, Worst negative: \(summary.longestNegativeStreak)d")
|
||||||
|
|
||||||
|
// Stability
|
||||||
|
lines.append("Stability: \(String(format: "%.0f", summary.moodStabilityScore * 100))%, Mood swings: \(summary.moodSwingCount)")
|
||||||
|
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,27 @@ struct InsightsView: View {
|
|||||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// AI badge
|
||||||
|
if viewModel.isAIAvailable {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
Text("AI")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.purple, .blue],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
@@ -36,6 +57,7 @@ struct InsightsView: View {
|
|||||||
title: "This Month",
|
title: "This Month",
|
||||||
icon: "calendar",
|
icon: "calendar",
|
||||||
insights: viewModel.monthInsights,
|
insights: viewModel.monthInsights,
|
||||||
|
loadingState: viewModel.monthLoadingState,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
moodTint: moodTint,
|
moodTint: moodTint,
|
||||||
imagePack: imagePack,
|
imagePack: imagePack,
|
||||||
@@ -47,6 +69,7 @@ struct InsightsView: View {
|
|||||||
title: "This Year",
|
title: "This Year",
|
||||||
icon: "calendar.badge.clock",
|
icon: "calendar.badge.clock",
|
||||||
insights: viewModel.yearInsights,
|
insights: viewModel.yearInsights,
|
||||||
|
loadingState: viewModel.yearLoadingState,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
moodTint: moodTint,
|
moodTint: moodTint,
|
||||||
imagePack: imagePack,
|
imagePack: imagePack,
|
||||||
@@ -58,6 +81,7 @@ struct InsightsView: View {
|
|||||||
title: "All Time",
|
title: "All Time",
|
||||||
icon: "infinity",
|
icon: "infinity",
|
||||||
insights: viewModel.allTimeInsights,
|
insights: viewModel.allTimeInsights,
|
||||||
|
loadingState: viewModel.allTimeLoadingState,
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
moodTint: moodTint,
|
moodTint: moodTint,
|
||||||
imagePack: imagePack,
|
imagePack: imagePack,
|
||||||
@@ -67,6 +91,11 @@ struct InsightsView: View {
|
|||||||
.padding(.vertical)
|
.padding(.vertical)
|
||||||
.padding(.bottom, 100)
|
.padding(.bottom, 100)
|
||||||
}
|
}
|
||||||
|
.refreshable {
|
||||||
|
viewModel.refreshInsights()
|
||||||
|
// Small delay to show refresh animation
|
||||||
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||||
|
}
|
||||||
.disabled(iapManager.shouldShowPaywall)
|
.disabled(iapManager.shouldShowPaywall)
|
||||||
|
|
||||||
if iapManager.shouldShowPaywall {
|
if iapManager.shouldShowPaywall {
|
||||||
@@ -108,10 +137,12 @@ struct InsightsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insights Section View
|
// MARK: - Insights Section View
|
||||||
|
|
||||||
struct InsightsSectionView: View {
|
struct InsightsSectionView: View {
|
||||||
let title: String
|
let title: String
|
||||||
let icon: String
|
let icon: String
|
||||||
let insights: [Insight]
|
let insights: [Insight]
|
||||||
|
let loadingState: InsightLoadingState
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
let moodTint: MoodTints
|
let moodTint: MoodTints
|
||||||
let imagePack: MoodImages
|
let imagePack: MoodImages
|
||||||
@@ -132,6 +163,13 @@ struct InsightsSectionView: View {
|
|||||||
.font(.system(size: 20, weight: .bold))
|
.font(.system(size: 20, weight: .bold))
|
||||||
.foregroundColor(textColor)
|
.foregroundColor(textColor)
|
||||||
|
|
||||||
|
// Loading indicator in header
|
||||||
|
if loadingState == .loading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.7)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||||
@@ -145,20 +183,61 @@ struct InsightsSectionView: View {
|
|||||||
|
|
||||||
// Insights List (collapsible)
|
// Insights List (collapsible)
|
||||||
if isExpanded {
|
if isExpanded {
|
||||||
VStack(spacing: 10) {
|
switch loadingState {
|
||||||
ForEach(insights) { insight in
|
case .loading:
|
||||||
InsightCardView(
|
// Skeleton loading view
|
||||||
insight: insight,
|
VStack(spacing: 10) {
|
||||||
textColor: textColor,
|
ForEach(0..<3, id: \.self) { _ in
|
||||||
moodTint: moodTint,
|
InsightSkeletonView(colorScheme: colorScheme)
|
||||||
imagePack: imagePack,
|
}
|
||||||
colorScheme: colorScheme
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.transition(.opacity)
|
||||||
|
|
||||||
|
case .error:
|
||||||
|
// Show insights (which contain error message) with error styling
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(insights) { insight in
|
||||||
|
InsightCardView(
|
||||||
|
insight: insight,
|
||||||
|
textColor: textColor,
|
||||||
|
moodTint: moodTint,
|
||||||
|
imagePack: imagePack,
|
||||||
|
colorScheme: colorScheme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
|
||||||
|
case .loaded, .idle:
|
||||||
|
// Normal insights display with staggered animation
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(Array(insights.enumerated()), id: \.element.id) { index, insight in
|
||||||
|
InsightCardView(
|
||||||
|
insight: insight,
|
||||||
|
textColor: textColor,
|
||||||
|
moodTint: moodTint,
|
||||||
|
imagePack: imagePack,
|
||||||
|
colorScheme: colorScheme
|
||||||
|
)
|
||||||
|
.transition(.asymmetric(
|
||||||
|
insertion: .opacity.combined(with: .scale(scale: 0.95)).combined(with: .offset(y: 10)),
|
||||||
|
removal: .opacity
|
||||||
|
))
|
||||||
|
.animation(
|
||||||
|
.spring(response: 0.4, dampingFraction: 0.8)
|
||||||
|
.delay(Double(index) * 0.05),
|
||||||
|
value: insights.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
|
||||||
.padding(.bottom, 16)
|
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(
|
.background(
|
||||||
@@ -166,10 +245,60 @@ struct InsightsSectionView: View {
|
|||||||
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
.fill(colorScheme == .dark ? Color(.systemGray6) : .white)
|
||||||
)
|
)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: isExpanded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skeleton Loading View
|
||||||
|
|
||||||
|
struct InsightSkeletonView: View {
|
||||||
|
let colorScheme: ColorScheme
|
||||||
|
@State private var isAnimating = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
// Icon placeholder
|
||||||
|
Circle()
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
// Text placeholders
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.frame(width: 120, height: 16)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 14)
|
||||||
|
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color.gray.opacity(0.2))
|
||||||
|
.frame(width: 180, height: 14)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(colorScheme == .dark ? Color(.systemGray5) : Color(.systemGray6))
|
||||||
|
)
|
||||||
|
.opacity(isAnimating ? 0.6 : 1.0)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.8)
|
||||||
|
.repeatForever(autoreverses: true),
|
||||||
|
value: isAnimating
|
||||||
|
)
|
||||||
|
.onAppear {
|
||||||
|
isAnimating = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Insight Card View
|
// MARK: - Insight Card View
|
||||||
|
|
||||||
struct InsightCardView: View {
|
struct InsightCardView: View {
|
||||||
let insight: Insight
|
let insight: Insight
|
||||||
let textColor: Color
|
let textColor: Color
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user