Proper 1.0 release for iOS. Pending App Store submission.
This commit is contained in:
18
ios/SessionStatusLive/AppIntent.swift
Normal file
18
ios/SessionStatusLive/AppIntent.swift
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
6
ios/SessionStatusLive/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
11
ios/SessionStatusLive/Info.plist
Normal file
11
ios/SessionStatusLive/Info.plist
Normal 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>
|
||||
381
ios/SessionStatusLive/SessionStatusLive.swift
Normal file
381
ios/SessionStatusLive/SessionStatusLive.swift
Normal 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"
|
||||
)
|
||||
}
|
||||
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal file
18
ios/SessionStatusLive/SessionStatusLiveBundle.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal file
77
ios/SessionStatusLive/SessionStatusLiveControl.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal file
223
ios/SessionStatusLive/SessionStatusLiveLiveActivity.swift
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user