All checks were successful
Ascently Docker Deploy / build-and-push (push) Successful in 2m31s
336 lines
10 KiB
Swift
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"
|
|
)
|
|
}
|