Add CoreData-based workout tracking app with iOS and watchOS targets
- Migrate from SwiftData to CoreData with CloudKit sync - Add core models: Split, Exercise, Workout, WorkoutLog - Implement tab-based UI: Workout Logs, Splits, Settings - Add SF Symbols picker for split icons - Add exercise picker filtered by split with exclusion of added exercises - Integrate IndieAbout for settings/about section - Add Yams for YAML exercise definition parsing - Include starter exercise libraries (bodyweight, Planet Fitness) - Add Date extensions for formatting (formattedTime, isSameDay) - Format workout date ranges to show time-only for same-day end dates - Add build number update script - Add app icons
This commit is contained in:
59
Workouts/Views/Common/CalendarListItem.swift
Normal file
59
Workouts/Views/Common/CalendarListItem.swift
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// CalendarListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/18/25 at 8:44 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CalendarListItem: View {
|
||||
var date: Date
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var subtitle2: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
ZStack {
|
||||
VStack {
|
||||
Text(date.abbreviatedWeekday)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("\(date.dayOfMonth)")
|
||||
.font(.headline)
|
||||
.foregroundColor(.accentColor)
|
||||
Text(date.abbreviatedMonth)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding([.trailing], 10)
|
||||
}
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
if let subtitle = subtitle {
|
||||
Text(subtitle)
|
||||
.font(.footnote)
|
||||
}
|
||||
if let subtitle2 = subtitle2 {
|
||||
Text(subtitle2)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
45
Workouts/Views/Common/CheckboxListItem.swift
Normal file
45
Workouts/Views/Common/CheckboxListItem.swift
Normal file
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// CheckboxListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CheckboxListItem: View {
|
||||
var status: CheckboxStatus
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
Image(systemName: status.systemName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 30)
|
||||
.foregroundStyle(status.color)
|
||||
VStack(alignment: .leading) {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
HStack(alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
48
Workouts/Views/Common/CheckboxStatus.swift
Normal file
48
Workouts/Views/Common/CheckboxStatus.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// CheckboxStatus.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/20/25 at 11:07 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
enum CheckboxStatus {
|
||||
case checked
|
||||
case unchecked
|
||||
case intermediate
|
||||
case cancelled
|
||||
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .checked: .green
|
||||
case .unchecked: .gray
|
||||
case .intermediate: .yellow
|
||||
case .cancelled: .red
|
||||
}
|
||||
}
|
||||
|
||||
var systemName: String {
|
||||
switch self {
|
||||
case .checked: "checkmark.circle.fill"
|
||||
case .unchecked: "circle"
|
||||
case .intermediate: "ellipsis.circle"
|
||||
case .cancelled: "xmark.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - WorkoutStatus Extension
|
||||
|
||||
extension WorkoutStatus {
|
||||
var checkboxStatus: CheckboxStatus {
|
||||
switch self {
|
||||
case .notStarted: .unchecked
|
||||
case .inProgress: .intermediate
|
||||
case .completed: .checked
|
||||
case .skipped: .cancelled
|
||||
}
|
||||
}
|
||||
}
|
||||
54
Workouts/Views/Common/ListItem.swift
Normal file
54
Workouts/Views/Common/ListItem.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
//
|
||||
// ListItem.swift
|
||||
// Workouts
|
||||
//
|
||||
// Created by rzen on 7/13/25 at 10:42 AM.
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ListItem: View {
|
||||
var systemName: String?
|
||||
var title: String?
|
||||
var text: String?
|
||||
var subtitle: String?
|
||||
var count: Int?
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if let systemName = systemName {
|
||||
Image(systemName: systemName)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
if let title = title {
|
||||
Text("\(title)")
|
||||
.font(.headline)
|
||||
if let text = text {
|
||||
Text("\(text)")
|
||||
.font(.footnote)
|
||||
}
|
||||
} else {
|
||||
if let text = text {
|
||||
Text("\(text)")
|
||||
}
|
||||
}
|
||||
HStack(alignment: .bottom) {
|
||||
if let subtitle = subtitle {
|
||||
Text("\(subtitle)")
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let count = count {
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
228
Workouts/Views/Common/SFSymbolPicker.swift
Normal file
228
Workouts/Views/Common/SFSymbolPicker.swift
Normal file
@@ -0,0 +1,228 @@
|
||||
//
|
||||
// SFSymbolPicker.swift
|
||||
// Workouts
|
||||
//
|
||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SFSymbolPicker: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var selection: String
|
||||
|
||||
@State private var searchText: String = ""
|
||||
|
||||
private let columns = [
|
||||
GridItem(.adaptive(minimum: 50, maximum: 60))
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
LazyVGrid(columns: columns, spacing: 16) {
|
||||
ForEach(filteredSymbols, id: \.self) { symbol in
|
||||
Button {
|
||||
selection = symbol
|
||||
dismiss()
|
||||
} label: {
|
||||
VStack {
|
||||
Image(systemName: symbol)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(selection == symbol ? Color.accentColor : Color.secondary.opacity(0.2))
|
||||
)
|
||||
.foregroundColor(selection == symbol ? .white : .primary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search symbols")
|
||||
.navigationTitle("Choose Icon")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredSymbols: [String] {
|
||||
if searchText.isEmpty {
|
||||
return Self.workoutSymbols
|
||||
}
|
||||
return Self.workoutSymbols.filter { $0.localizedCaseInsensitiveContains(searchText) }
|
||||
}
|
||||
|
||||
// Curated list of workout/fitness-related SF Symbols
|
||||
static let workoutSymbols: [String] = [
|
||||
// Fitness & Exercise
|
||||
"dumbbell.fill",
|
||||
"dumbbell",
|
||||
"figure.strengthtraining.traditional",
|
||||
"figure.strengthtraining.functional",
|
||||
"figure.cross.training",
|
||||
"figure.core.training",
|
||||
"figure.cooldown",
|
||||
"figure.flexibility",
|
||||
"figure.pilates",
|
||||
"figure.yoga",
|
||||
"figure.highintensity.intervaltraining",
|
||||
"figure.mixed.cardio",
|
||||
"figure.rower",
|
||||
"figure.elliptical",
|
||||
"figure.stair.stepper",
|
||||
"figure.step.training",
|
||||
|
||||
// Running & Walking
|
||||
"figure.run",
|
||||
"figure.run.circle",
|
||||
"figure.run.circle.fill",
|
||||
"figure.walk",
|
||||
"figure.walk.circle",
|
||||
"figure.walk.circle.fill",
|
||||
"figure.hiking",
|
||||
"figure.outdoor.cycle",
|
||||
"figure.indoor.cycle",
|
||||
|
||||
// Sports
|
||||
"figure.boxing",
|
||||
"figure.kickboxing",
|
||||
"figure.martial.arts",
|
||||
"figure.wrestling",
|
||||
"figure.gymnastics",
|
||||
"figure.handball",
|
||||
"figure.basketball",
|
||||
"figure.tennis",
|
||||
"figure.badminton",
|
||||
"figure.racquetball",
|
||||
"figure.squash",
|
||||
"figure.volleyball",
|
||||
"figure.baseball",
|
||||
"figure.softball",
|
||||
"figure.golf",
|
||||
"figure.soccer",
|
||||
"figure.american.football",
|
||||
"figure.rugby",
|
||||
"figure.hockey",
|
||||
"figure.lacrosse",
|
||||
"figure.cricket",
|
||||
"figure.table.tennis",
|
||||
"figure.fencing",
|
||||
"figure.archery",
|
||||
"figure.bowling",
|
||||
"figure.disc.sports",
|
||||
|
||||
// Water Sports
|
||||
"figure.pool.swim",
|
||||
"figure.open.water.swim",
|
||||
"figure.surfing",
|
||||
"figure.waterpolo",
|
||||
"figure.rowing",
|
||||
"figure.sailing",
|
||||
"figure.fishing",
|
||||
|
||||
// Winter Sports
|
||||
"figure.skiing.downhill",
|
||||
"figure.skiing.crosscountry",
|
||||
"figure.snowboarding",
|
||||
"figure.skating",
|
||||
|
||||
// Climbing & Adventure
|
||||
"figure.climbing",
|
||||
"figure.equestrian.sports",
|
||||
"figure.hunting",
|
||||
|
||||
// Mind & Body
|
||||
"figure.mind.and.body",
|
||||
"figure.dance",
|
||||
"figure.barre",
|
||||
"figure.socialdance",
|
||||
"figure.australian.football",
|
||||
|
||||
// General Activity
|
||||
"figure.stand",
|
||||
"figure.wave",
|
||||
"figure.roll",
|
||||
"figure.jumprope",
|
||||
"figure.play",
|
||||
"figure.child",
|
||||
|
||||
// Health & Body
|
||||
"heart.fill",
|
||||
"heart",
|
||||
"heart.circle",
|
||||
"heart.circle.fill",
|
||||
"bolt.heart.fill",
|
||||
"bolt.heart",
|
||||
"waveform.path.ecg",
|
||||
"lungs.fill",
|
||||
"lungs",
|
||||
|
||||
// Energy & Power
|
||||
"bolt.fill",
|
||||
"bolt",
|
||||
"bolt.circle",
|
||||
"bolt.circle.fill",
|
||||
"flame.fill",
|
||||
"flame",
|
||||
"flame.circle",
|
||||
"flame.circle.fill",
|
||||
|
||||
// Timer & Tracking
|
||||
"stopwatch",
|
||||
"stopwatch.fill",
|
||||
"timer",
|
||||
"timer.circle",
|
||||
"timer.circle.fill",
|
||||
"clock",
|
||||
"clock.fill",
|
||||
|
||||
// Progress & Goals
|
||||
"trophy.fill",
|
||||
"trophy",
|
||||
"trophy.circle",
|
||||
"trophy.circle.fill",
|
||||
"medal.fill",
|
||||
"medal",
|
||||
"star.fill",
|
||||
"star",
|
||||
"star.circle",
|
||||
"star.circle.fill",
|
||||
"target",
|
||||
"scope",
|
||||
"chart.bar.fill",
|
||||
"chart.line.uptrend.xyaxis",
|
||||
"arrow.up.circle.fill",
|
||||
|
||||
// Misc
|
||||
"scalemass.fill",
|
||||
"scalemass",
|
||||
"bed.double.fill",
|
||||
"bed.double",
|
||||
"moon.fill",
|
||||
"moon",
|
||||
"sun.max.fill",
|
||||
"sun.max",
|
||||
"drop.fill",
|
||||
"drop",
|
||||
"leaf.fill",
|
||||
"leaf",
|
||||
"carrot.fill",
|
||||
"carrot",
|
||||
"fork.knife",
|
||||
"cup.and.saucer.fill",
|
||||
]
|
||||
}
|
||||
|
||||
#Preview {
|
||||
SFSymbolPicker(selection: .constant("dumbbell.fill"))
|
||||
}
|
||||
Reference in New Issue
Block a user