Files
Ascently/ios/SessionStatusLive/SessionStatusLive.swift
Atridad Lahiji 09b4055985
All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
Moved to Ascently
2025-10-13 14:54:54 -06:00

336 lines
10 KiB
Swift

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