// // SessionStatusLive.swift // SessionStatusLive // // Created by Atridad Lahiji on 2025-09-15. // import SwiftUI import WidgetKit struct ClimbingStatsProvider: TimelineProvider { typealias Entry = ClimbingStatsEntry func placeholder(in context: Context) -> ClimbingStatsEntry { ClimbingStatsEntry( date: Date(), weeklyAttempts: 42, todayAttempts: 8, currentStreak: 3, favoriteGym: "Summit Climbing" ) } func getSnapshot(in context: Context, completion: @escaping (ClimbingStatsEntry) -> Void) { let entry = ClimbingStatsEntry( date: Date(), weeklyAttempts: 42, todayAttempts: 8, currentStreak: 3, favoriteGym: "Summit Climbing" ) completion(entry) } func getTimeline( in context: Context, completion: @escaping (Timeline) -> Void ) { let currentDate = Date() let stats = loadClimbingStats() let entry = ClimbingStatsEntry( date: currentDate, weeklyAttempts: stats.weeklyAttempts, todayAttempts: stats.todayAttempts, currentStreak: stats.currentStreak, favoriteGym: stats.favoriteGym ) // Update every hour let nextUpdate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) completion(timeline) } private func loadClimbingStats() -> ClimbingStats { let userDefaults = UserDefaults(suiteName: "group.com.atridad.OpenClimb") // Load attempts from UserDefaults guard let attemptsData = userDefaults?.data(forKey: "openclimb_attempts"), let attempts = try? JSONDecoder().decode([WidgetAttempt].self, from: attemptsData) else { return ClimbingStats( weeklyAttempts: 0, todayAttempts: 0, currentStreak: 0, favoriteGym: "No Data") } // Load sessions for streak calculation let sessionsData = (userDefaults?.data(forKey: "openclimb_sessions"))! let sessions = (try? JSONDecoder().decode([WidgetSession].self, from: sessionsData)) ?? [] // Load gyms for favorite gym name let gymsData = (userDefaults?.data(forKey: "openclimb_gyms"))! let gyms = (try? JSONDecoder().decode([WidgetGym].self, from: gymsData)) ?? [] let calendar = Calendar.current let now = Date() let weekAgo = calendar.date(byAdding: .day, value: -7, to: now)! let startOfToday = calendar.startOfDay(for: now) // Calculate weekly attempts let weeklyAttempts = attempts.filter { attempt in attempt.timestamp >= weekAgo }.count // Calculate today's attempts let todayAttempts = attempts.filter { attempt in attempt.timestamp >= startOfToday }.count // Calculate current streak (consecutive days with sessions) let currentStreak = calculateStreak(sessions: sessions) // Find favorite gym let favoriteGym = findFavoriteGym(sessions: sessions, gyms: gyms) return ClimbingStats( weeklyAttempts: weeklyAttempts, todayAttempts: todayAttempts, currentStreak: currentStreak, favoriteGym: favoriteGym ) } private func calculateStreak(sessions: [WidgetSession]) -> Int { let calendar = Calendar.current let completedSessions = sessions.filter { $0.status == "COMPLETED" } .sorted { $0.date > $1.date } guard !completedSessions.isEmpty else { return 0 } var streak = 0 var currentDate = calendar.startOfDay(for: Date()) for session in completedSessions { let sessionDate = calendar.startOfDay(for: session.date) if sessionDate == currentDate { streak += 1 currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)! } else if sessionDate == calendar.date(byAdding: .day, value: -1, to: currentDate) { streak += 1 currentDate = calendar.date(byAdding: .day, value: -1, to: currentDate)! } else { break } } return streak } private func findFavoriteGym(sessions: [WidgetSession], gyms: [WidgetGym]) -> String { let gymCounts = Dictionary(grouping: sessions, by: { $0.gymId }) .mapValues { $0.count } guard let mostUsedGymId = gymCounts.max(by: { $0.value < $1.value })?.key, let gym = gyms.first(where: { $0.id == mostUsedGymId }) else { return "No Data" } return gym.name } } struct ClimbingStatsEntry: TimelineEntry { let date: Date let weeklyAttempts: Int let todayAttempts: Int let currentStreak: Int let favoriteGym: String } struct ClimbingStats { let weeklyAttempts: Int let todayAttempts: Int let currentStreak: Int let favoriteGym: String } struct SessionStatusLiveEntryView: View { var entry: ClimbingStatsEntry @Environment(\.widgetFamily) var family var body: some View { switch family { case .systemSmall: SmallWidgetView(entry: entry) case .systemMedium: MediumWidgetView(entry: entry) default: SmallWidgetView(entry: entry) } } } struct SmallWidgetView: View { let entry: ClimbingStatsEntry var body: some View { VStack(spacing: 8) { // Header HStack { if let uiImage = UIImage(named: "AppIcon") { Image(uiImage: uiImage) .resizable() .frame(width: 24, height: 24) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { Image(systemName: "figure.climbing") .font(.title2) .foregroundColor(.accentColor) } Spacer() Text("This Week") .font(.caption) .foregroundColor(.secondary) } // Main stat - weekly attempts VStack(spacing: 2) { Text("\(entry.weeklyAttempts)") .font(.largeTitle) .fontWeight(.bold) .foregroundColor(.primary) Text("Attempts") .font(.caption) .foregroundColor(.secondary) } Spacer() // Bottom stats HStack { VStack(alignment: .leading, spacing: 2) { Text("\(entry.todayAttempts)") .font(.headline) .fontWeight(.semibold) Text("Today") .font(.caption2) .foregroundColor(.secondary) } Spacer() VStack(alignment: .trailing, spacing: 2) { HStack(spacing: 2) { Text("\(entry.currentStreak)") .font(.headline) .fontWeight(.semibold) Image(systemName: "flame.fill") .foregroundColor(.orange) .font(.caption) } Text("Day Streak") .font(.caption2) .foregroundColor(.secondary) } } } .padding() } } struct MediumWidgetView: View { let entry: ClimbingStatsEntry var body: some View { VStack(spacing: 12) { // Header HStack { HStack(spacing: 6) { if let uiImage = UIImage(named: "AppIcon") { Image(uiImage: uiImage) .resizable() .frame(width: 24, height: 24) .clipShape(RoundedRectangle(cornerRadius: 6)) } else { Image(systemName: "figure.climbing") .font(.title2) .foregroundColor(.accentColor) } Text("Climbing Stats") .font(.headline) .fontWeight(.semibold) } Spacer() Text("This Week") .font(.caption) .foregroundColor(.secondary) } // Main stats row HStack(spacing: 20) { VStack(spacing: 4) { Text("\(entry.weeklyAttempts)") .font(.title) .fontWeight(.bold) .foregroundColor(.primary) Text("Total Attempts") .font(.caption) .foregroundColor(.secondary) } VStack(spacing: 4) { Text("\(entry.todayAttempts)") .font(.title) .fontWeight(.bold) .foregroundColor(.blue) Text("Today") .font(.caption) .foregroundColor(.secondary) } VStack(spacing: 4) { HStack(spacing: 4) { Text("\(entry.currentStreak)") .font(.title) .fontWeight(.bold) .foregroundColor(.orange) Image(systemName: "flame.fill") .foregroundColor(.orange) .font(.title3) } Text("Day Streak") .font(.caption) .foregroundColor(.secondary) } } Spacer() // Bottom info HStack { VStack(alignment: .leading, spacing: 2) { Text("Favorite Gym") .font(.caption2) .foregroundColor(.secondary) Text(entry.favoriteGym) .font(.caption) .fontWeight(.medium) .lineLimit(1) } Spacer() } } .padding() } } struct SessionStatusLive: Widget { let kind: String = "SessionStatusLive" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: ClimbingStatsProvider()) { entry in SessionStatusLiveEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } .configurationDisplayName("Climbing Stats") .description("Track your climbing attempts and streaks") .supportedFamilies([.systemSmall, .systemMedium]) } } // Simplified data models for widget use struct WidgetAttempt: Codable { let id: String let sessionId: String let problemId: String let timestamp: Date let result: String } struct WidgetSession: Codable { let id: String let gymId: String let date: Date let status: String } struct WidgetGym: Codable { let id: String let name: String } #Preview(as: .systemSmall) { SessionStatusLive() } timeline: { ClimbingStatsEntry( date: .now, weeklyAttempts: 42, todayAttempts: 8, currentStreak: 3, favoriteGym: "Summit Climbing" ) ClimbingStatsEntry( date: .now, weeklyAttempts: 58, todayAttempts: 12, currentStreak: 5, favoriteGym: "Boulder Zone" ) }