Files
Ascently/ios/SessionStatusLive/SessionStatusLive.swift

382 lines
12 KiB
Swift

//
// 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<ClimbingStatsEntry>) -> 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"
)
}