Proper 1.0 release for iOS. Pending App Store submission.

This commit is contained in:
2025-09-15 21:01:02 -06:00
parent d95c45abbb
commit afd954785a
24 changed files with 1848 additions and 2 deletions

View File

@@ -0,0 +1,18 @@
//
// AppIntent.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import AppIntents
struct ConfigurationAppIntent: WidgetConfigurationIntent {
static var title: LocalizedStringResource { "Configuration" }
static var description: IntentDescription { "This is an example widget." }
// An example configurable parameter.
@Parameter(title: "Favorite Emoji", default: "😃")
var favoriteEmoji: String
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,35 @@
{
"images" : [
{
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "tinted"
}
],
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
{
"colors" : [
{
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,381 @@
//
// 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"
)
}

View File

@@ -0,0 +1,18 @@
//
// SessionStatusLiveBundle.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import WidgetKit
import SwiftUI
@main
struct SessionStatusLiveBundle: WidgetBundle {
var body: some Widget {
SessionStatusLive()
SessionStatusLiveControl()
SessionStatusLiveLiveActivity()
}
}

View File

@@ -0,0 +1,77 @@
//
// SessionStatusLiveControl.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import AppIntents
import SwiftUI
import WidgetKit
struct SessionStatusLiveControl: ControlWidget {
static let kind: String = "com.atridad.OpenClimb.SessionStatusLive"
var body: some ControlWidgetConfiguration {
AppIntentControlConfiguration(
kind: Self.kind,
provider: Provider()
) { value in
ControlWidgetToggle(
"Start Timer",
isOn: value.isRunning,
action: StartTimerIntent(value.name)
) { isRunning in
Label(isRunning ? "On" : "Off", systemImage: "timer")
}
}
.displayName("Timer")
.description("A an example control that runs a timer.")
}
}
extension SessionStatusLiveControl {
struct Value {
var isRunning: Bool
var name: String
}
struct Provider: AppIntentControlValueProvider {
func previewValue(configuration: TimerConfiguration) -> Value {
SessionStatusLiveControl.Value(isRunning: false, name: configuration.timerName)
}
func currentValue(configuration: TimerConfiguration) async throws -> Value {
let isRunning = true // Check if the timer is running
return SessionStatusLiveControl.Value(isRunning: isRunning, name: configuration.timerName)
}
}
}
struct TimerConfiguration: ControlConfigurationIntent {
static let title: LocalizedStringResource = "Timer Name Configuration"
@Parameter(title: "Timer Name", default: "Timer")
var timerName: String
}
struct StartTimerIntent: SetValueIntent {
static let title: LocalizedStringResource = "Start a timer"
@Parameter(title: "Timer Name")
var name: String
@Parameter(title: "Timer is running")
var value: Bool
init() {}
init(_ name: String) {
self.name = name
}
func perform() async throws -> some IntentResult {
// Start the timer
return .result()
}
}

View File

@@ -0,0 +1,223 @@
//
// SessionStatusLiveLiveActivity.swift
// SessionStatusLive
//
// Created by Atridad Lahiji on 2025-09-15.
//
import ActivityKit
import SwiftUI
import WidgetKit
struct SessionActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var elapsed: TimeInterval
var totalAttempts: Int
var completedProblems: Int
}
var gymName: String
var startTime: Date
}
struct SessionStatusLiveLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: SessionActivityAttributes.self) { context in
LiveActivityView(context: context)
.activityBackgroundTint(Color.blue.opacity(0.2))
.activitySystemActionForegroundColor(Color.primary)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) {
VStack(alignment: .leading, spacing: 4) {
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(.title3)
.foregroundColor(.accentColor)
}
Text(context.attributes.gymName)
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 4) {
LiveTimerView(start: context.attributes.startTime)
.font(.title2)
.fontWeight(.bold)
.monospacedDigit()
HStack(spacing: 4) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.font(.caption)
Text("\(context.state.totalAttempts)")
.font(.caption)
.fontWeight(.semibold)
Text("attempts")
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
DynamicIslandExpandedRegion(.bottom) {
HStack {
Text("\(context.state.completedProblems) completed")
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text("Tap to open")
.font(.caption2)
.foregroundColor(.secondary)
}
}
} compactLeading: {
Image(systemName: "figure.climbing")
.font(.footnote)
.foregroundColor(.accentColor)
} compactTrailing: {
LiveTimerView(start: context.attributes.startTime, compact: true)
} minimal: {
Image(systemName: "figure.climbing")
.font(.system(size: 8))
.foregroundColor(.accentColor)
}
}
}
}
struct LiveActivityView: View {
let context: ActivityViewContext<SessionActivityAttributes>
var body: some View {
HStack(spacing: 16) {
LiveTimerView(start: context.attributes.startTime)
.font(.largeTitle)
.fontWeight(.bold)
.monospacedDigit()
.frame(minWidth: 80)
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 6) {
if let uiImage = UIImage(named: "AppIcon") {
Image(uiImage: uiImage)
.resizable()
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 7))
} else {
Image(systemName: "figure.climbing")
.font(.title2)
.foregroundColor(.accentColor)
}
VStack(alignment: .leading, spacing: 0) {
Text(context.attributes.gymName)
.font(.headline)
.fontWeight(.semibold)
.lineLimit(1)
Text("Climbing Session")
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(spacing: 20) {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "flame.fill")
.foregroundColor(.orange)
.font(.title3)
Text("\(context.state.totalAttempts)")
.font(.title2)
.fontWeight(.bold)
}
Text("Total Attempts")
.font(.caption)
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.title3)
Text("\(context.state.completedProblems)")
.font(.title2)
.fontWeight(.bold)
}
Text("Completed")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
struct LiveTimerView: View {
let start: Date
let compact: Bool
let minimal: Bool
init(start: Date, compact: Bool = false, minimal: Bool = false) {
self.start = start
self.compact = compact
self.minimal = minimal
}
var body: some View {
if minimal {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.system(size: 8, weight: .medium, design: .monospaced))
} else if compact {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.font(.caption.monospacedDigit())
.frame(maxWidth: 40)
.minimumScaleFactor(0.7)
} else {
Text(timerInterval: start...Date.distantFuture, countsDown: false)
.monospacedDigit()
}
}
}
// Alias for compatibility
typealias TimerView = LiveTimerView
extension SessionActivityAttributes {
fileprivate static var preview: SessionActivityAttributes {
SessionActivityAttributes(
gymName: "Summit Climbing Gym", startTime: Date().addingTimeInterval(-1234))
}
}
extension SessionActivityAttributes.ContentState {
fileprivate static var active: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 1234, totalAttempts: 8, completedProblems: 2)
}
fileprivate static var busy: SessionActivityAttributes.ContentState {
SessionActivityAttributes.ContentState(
elapsed: 3600, totalAttempts: 25, completedProblems: 7)
}
}
#Preview("Notification", as: .content, using: SessionActivityAttributes.preview) {
SessionStatusLiveLiveActivity()
} contentStates: {
SessionActivityAttributes.ContentState.active
SessionActivityAttributes.ContentState.busy
}