// // SessionStatusLive.swift import SwiftUI import WidgetKit struct ClimbingStatsProvider: TimelineProvider { typealias Entry = ClimbingStatsEntry func placeholder(in context: Context) -> ClimbingStatsEntry { ClimbingStatsEntry( date: Date(), weeklyAttempts: 42, weeklySessions: 5, currentStreak: 3, favoriteGym: "Summit Climbing" ) } func getSnapshot(in context: Context, completion: @escaping (ClimbingStatsEntry) -> Void) { let entry = ClimbingStatsEntry( date: Date(), weeklyAttempts: 42, weeklySessions: 5, 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, weeklySessions: stats.weeklySessions, 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.Ascently") // Load attempts from UserDefaults guard let attemptsData = userDefaults?.data(forKey: "ascently_attempts"), let attempts = try? JSONDecoder().decode([WidgetAttempt].self, from: attemptsData) else { return ClimbingStats( weeklyAttempts: 0, weeklySessions: 0, currentStreak: 0, favoriteGym: "No Data") } // Load sessions for streak calculation let sessionsData = (userDefaults?.data(forKey: "ascently_sessions"))! let sessions = (try? JSONDecoder().decode([WidgetSession].self, from: sessionsData)) ?? [] // Load gyms for favorite gym name let gymsData = (userDefaults?.data(forKey: "ascently_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)! _ = calendar.startOfDay(for: now) // Calculate weekly attempts let weeklyAttempts = attempts.filter { attempt in attempt.timestamp >= weekAgo }.count // Calculate weekly sessions let weeklySessions = sessions.filter { session in session.date >= weekAgo && session.status == "COMPLETED" }.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, weeklySessions: weeklySessions, 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 weeklySessions: Int let currentStreak: Int let favoriteGym: String } struct ClimbingStats { let weeklyAttempts: Int let weeklySessions: 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: 12) { // 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("Weekly") .font(.caption) .foregroundColor(.secondary) } Spacer() // Main stats - weekly attempts and sessions VStack(spacing: 16) { HStack(spacing: 8) { Image(systemName: "hand.raised.fill") .foregroundColor(.green) .font(.title2) Text("\(entry.weeklyAttempts)") .font(.title) .fontWeight(.bold) .foregroundColor(.primary) } HStack(spacing: 8) { Image(systemName: "play.fill") .foregroundColor(.blue) .font(.title2) Text("\(entry.weeklySessions)") .font(.title) .fontWeight(.bold) .foregroundColor(.primary) } } Spacer() } .padding() } } struct MediumWidgetView: View { let entry: ClimbingStatsEntry var body: some View { VStack(spacing: 16) { // 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("Weekly") .font(.headline) .fontWeight(.semibold) } Spacer() } // Main stats row - weekly attempts and sessions HStack(spacing: 40) { VStack(spacing: 8) { HStack(spacing: 8) { Image(systemName: "hand.raised.fill") .foregroundColor(.green) .font(.title2) Text("\(entry.weeklyAttempts)") .font(.title) .fontWeight(.bold) .foregroundColor(.primary) } } VStack(spacing: 8) { HStack(spacing: 8) { Image(systemName: "play.fill") .foregroundColor(.blue) .font(.title2) Text("\(entry.weeklySessions)") .font(.title) .fontWeight(.bold) .foregroundColor(.primary) } } } 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, weeklySessions: 5, currentStreak: 3, favoriteGym: "Summit Climbing" ) ClimbingStatsEntry( date: .now, weeklyAttempts: 58, weeklySessions: 8, currentStreak: 5, favoriteGym: "Boulder Zone" ) }