Open new workouts directly and improve accessibility
Starting a workout — from the split picker or a split's exercise list — now drops straight into its log screen once the cache catches up, via a shared StartedWorkoutNavigator. Adds VoiceOver labels/values to the log checkboxes and the settings button, a color-independent numbered legend and spoken summary to the heart-rate-zone bar, and Dynamic Type scaling to the run-flow badges and timer. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -89,8 +89,9 @@ struct ExerciseFigureView: View {
|
|||||||
draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate)
|
draw(&ctx, size: size, time: context.date.timeIntervalSinceReferenceDate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.accessibilityLabel("Animated form guide")
|
// Purely decorative: the paged flow already conveys the exercise and its
|
||||||
.accessibilityHidden(false)
|
// progress, so the looping figure is skipped by VoiceOver.
|
||||||
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) {
|
private func draw(_ ctx: inout GraphicsContext, size: CGSize, time: Double) {
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ struct CalendarListItem: View {
|
|||||||
}
|
}
|
||||||
.padding([.trailing], 10)
|
.padding([.trailing], 10)
|
||||||
}
|
}
|
||||||
|
// Read the date as one phrase instead of three disjoint "Mon" / "14" / "Jul" stops.
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel(date.formatted(date: .complete, time: .omitted))
|
||||||
HStack(alignment: .top) {
|
HStack(alignment: .top) {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(title)
|
Text(title)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import SwiftData
|
|||||||
|
|
||||||
struct ExerciseListView: View {
|
struct ExerciseListView: View {
|
||||||
@Environment(SyncEngine.self) private var sync
|
@Environment(SyncEngine.self) private var sync
|
||||||
@Environment(\.modelContext) private var modelContext
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
// Resolve the split by id, not a captured entity: editing a seed's exercise from
|
// Resolve the split by id, not a captured entity: editing a seed's exercise from
|
||||||
@@ -26,9 +25,9 @@ struct ExerciseListView: View {
|
|||||||
@State private var itemToEdit: Exercise? = nil
|
@State private var itemToEdit: Exercise? = nil
|
||||||
@State private var itemToDelete: Exercise? = nil
|
@State private var itemToDelete: Exercise? = nil
|
||||||
/// ID of the just-created workout; drives programmatic navigation once the
|
/// ID of the just-created workout; drives programmatic navigation once the
|
||||||
/// cache observer delivers the entity a beat after the file write.
|
/// cache observer delivers the entity a beat after the file write (see
|
||||||
|
/// `navigatesToStartedWorkout`).
|
||||||
@State private var pendingWorkoutID: String? = nil
|
@State private var pendingWorkoutID: String? = nil
|
||||||
@State private var resolvedWorkout: Workout? = nil
|
|
||||||
|
|
||||||
@Query(sort: \Workout.start, order: .reverse)
|
@Query(sort: \Workout.start, order: .reverse)
|
||||||
private var workouts: [Workout]
|
private var workouts: [Workout]
|
||||||
@@ -58,15 +57,8 @@ struct ExerciseListView: View {
|
|||||||
.task { dismiss() }
|
.task { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Navigate to the workout log once the entity appears in the cache.
|
// Navigate into the workout's log screen once the entity appears in the cache.
|
||||||
.navigationDestination(item: $resolvedWorkout) { workout in
|
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||||
WorkoutLogListView(workout: workout)
|
|
||||||
}
|
|
||||||
// Poll for the entity after we write the document.
|
|
||||||
.onChange(of: pendingWorkoutID) { _, id in
|
|
||||||
guard let id else { return }
|
|
||||||
pollForWorkout(id: id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -192,22 +184,6 @@ struct ExerciseListView: View {
|
|||||||
start()
|
start()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pollForWorkout(id: String) {
|
|
||||||
Task {
|
|
||||||
// Give the file→observer→cache loop a moment to complete (typically < 1 s).
|
|
||||||
for _ in 0..<20 {
|
|
||||||
try? await Task.sleep(for: .milliseconds(150))
|
|
||||||
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
|
||||||
resolvedWorkout = workout
|
|
||||||
pendingWorkoutID = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If still not available after ~3 s, clear the pending ID silently.
|
|
||||||
pendingWorkoutID = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func moveExercises(from source: IndexSet, to destination: Int) {
|
private func moveExercises(from source: IndexSet, to destination: Int) {
|
||||||
guard let split else { return }
|
guard let split else { return }
|
||||||
var exercises = split.exercisesArray
|
var exercises = split.exercisesArray
|
||||||
|
|||||||
@@ -621,6 +621,10 @@ private struct PhaseTimerLayout<Content: View>: View {
|
|||||||
let tint: Color
|
let tint: Color
|
||||||
@ViewBuilder var timer: Content
|
@ViewBuilder var timer: Content
|
||||||
|
|
||||||
|
/// Scales the big timer digits with Dynamic Type (falls back to `.minimumScaleFactor`
|
||||||
|
/// so the largest sizes never overflow the top half).
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var timerFontSize: CGFloat = 108
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
Text(header)
|
Text(header)
|
||||||
@@ -628,7 +632,7 @@ private struct PhaseTimerLayout<Content: View>: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
timer
|
timer
|
||||||
.font(.system(size: 108, weight: .bold, design: .rounded))
|
.font(.system(size: timerFontSize, weight: .bold, design: .rounded))
|
||||||
.monospacedDigit()
|
.monospacedDigit()
|
||||||
.foregroundStyle(tint)
|
.foregroundStyle(tint)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@@ -715,6 +719,9 @@ private extension View {
|
|||||||
private struct CompletedPhaseView: View {
|
private struct CompletedPhaseView: View {
|
||||||
let log: WorkoutLogDocument?
|
let log: WorkoutLogDocument?
|
||||||
|
|
||||||
|
/// Scales the completion badge with Dynamic Type.
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
|
||||||
|
|
||||||
/// "4 sets × 12 reps" / "3 sets × 45 sec".
|
/// "4 sets × 12 reps" / "3 sets × 45 sec".
|
||||||
private var planSummary: String {
|
private var planSummary: String {
|
||||||
guard let log else { return "" }
|
guard let log else { return "" }
|
||||||
@@ -736,7 +743,7 @@ private struct CompletedPhaseView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.font(.system(size: 96, weight: .bold))
|
.font(.system(size: badgeSize, weight: .bold))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
Text("Completed")
|
Text("Completed")
|
||||||
.font(.system(.title, design: .rounded, weight: .heavy))
|
.font(.system(.title, design: .rounded, weight: .heavy))
|
||||||
@@ -766,10 +773,13 @@ private struct CompletedPhaseView: View {
|
|||||||
/// ended early with this one unfinished). Same badge treatment as Completed,
|
/// ended early with this one unfinished). Same badge treatment as Completed,
|
||||||
/// in gray, matching the list row's skipped icon.
|
/// in gray, matching the list row's skipped icon.
|
||||||
private struct SkippedPhaseView: View {
|
private struct SkippedPhaseView: View {
|
||||||
|
/// Scales the skipped badge with Dynamic Type.
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var badgeSize: CGFloat = 96
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Image(systemName: "wrongwaysign")
|
Image(systemName: "wrongwaysign")
|
||||||
.font(.system(size: 96, weight: .bold))
|
.font(.system(size: badgeSize, weight: .bold))
|
||||||
.foregroundStyle(.gray)
|
.foregroundStyle(.gray)
|
||||||
Text("Skipped")
|
Text("Skipped")
|
||||||
.font(.system(.title, design: .rounded, weight: .heavy))
|
.font(.system(.title, design: .rounded, weight: .heavy))
|
||||||
@@ -785,10 +795,15 @@ private struct ReadyPhaseView: View {
|
|||||||
let summary: String
|
let summary: String
|
||||||
let onStart: () -> Void
|
let onStart: () -> Void
|
||||||
|
|
||||||
|
/// Scales the "Ready?" headline with Dynamic Type.
|
||||||
|
@ScaledMetric(relativeTo: .largeTitle) private var readyFontSize: CGFloat = 44
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
Text("Ready?")
|
Text("Ready?")
|
||||||
.font(.system(size: 44, weight: .bold, design: .rounded))
|
.font(.system(size: readyFontSize, weight: .bold, design: .rounded))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
|
|
||||||
if !summary.isEmpty {
|
if !summary.isEmpty {
|
||||||
Text(summary)
|
Text(summary)
|
||||||
|
|||||||
@@ -443,6 +443,19 @@ private struct WorkoutLogRow: View {
|
|||||||
|
|
||||||
private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none }
|
private var loadType: LoadType { LoadType(rawValue: log.loadType) ?? .none }
|
||||||
|
|
||||||
|
/// Spoken status for the checkbox: the same states the icon encodes, plus the
|
||||||
|
/// set progress while an exercise is mid-flight (which the icon can't show).
|
||||||
|
private var statusAccessibilityValue: String {
|
||||||
|
switch WorkoutStatus(rawValue: log.status) ?? .notStarted {
|
||||||
|
case .notStarted: return "Not started"
|
||||||
|
case .inProgress:
|
||||||
|
let done = min(max(0, log.currentStateIndex), log.sets)
|
||||||
|
return "In progress, \(done) of \(log.sets) sets"
|
||||||
|
case .completed: return "Completed"
|
||||||
|
case .skipped: return "Skipped"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
||||||
/// strength and the × dimmed, so the counts read at a glance.
|
/// strength and the × dimmed, so the counts read at a glance.
|
||||||
private var setsAndReps: Text {
|
private var setsAndReps: Text {
|
||||||
@@ -497,6 +510,9 @@ private struct WorkoutLogRow: View {
|
|||||||
.foregroundStyle(status.color)
|
.foregroundStyle(status.color)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(log.exerciseName)
|
||||||
|
.accessibilityValue(statusAccessibilityValue)
|
||||||
|
.accessibilityHint("Advances this exercise's completion status")
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(log.exerciseName)
|
Text(log.exerciseName)
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ struct WorkoutLogsView: View {
|
|||||||
@State private var showingSplitPicker = false
|
@State private var showingSplitPicker = false
|
||||||
@State private var showingSettings = false
|
@State private var showingSettings = false
|
||||||
@State private var itemToDelete: Workout?
|
@State private var itemToDelete: Workout?
|
||||||
|
/// ID of the just-started workout; drives the push into its log screen once the
|
||||||
|
/// cache observer delivers the entity a beat after the file write (see
|
||||||
|
/// `navigatesToStartedWorkout`).
|
||||||
|
@State private var pendingWorkoutID: String?
|
||||||
|
|
||||||
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
|
// WorkoutLogsView is the app's root screen, so it owns its NavigationStack.
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -64,6 +68,7 @@ struct WorkoutLogsView: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "gearshape.2")
|
Image(systemName: "gearshape.2")
|
||||||
}
|
}
|
||||||
|
.accessibilityLabel("Settings")
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
Button("Start New") {
|
Button("Start New") {
|
||||||
@@ -75,8 +80,11 @@ struct WorkoutLogsView: View {
|
|||||||
SettingsView()
|
SettingsView()
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showingSplitPicker) {
|
.sheet(isPresented: $showingSplitPicker) {
|
||||||
SplitPickerSheet()
|
SplitPickerSheet { pendingWorkoutID = $0 }
|
||||||
}
|
}
|
||||||
|
// Once "Start New" saves a workout, drop straight into its log screen —
|
||||||
|
// the same landing the split's exercise-list start path uses.
|
||||||
|
.navigatesToStartedWorkout(pendingWorkoutID: $pendingWorkoutID)
|
||||||
.confirmationDialog(
|
.confirmationDialog(
|
||||||
"Delete Workout?",
|
"Delete Workout?",
|
||||||
isPresented: Binding(
|
isPresented: Binding(
|
||||||
@@ -126,6 +134,10 @@ struct SplitPickerSheet: View {
|
|||||||
@Environment(AppServices.self) private var services
|
@Environment(AppServices.self) private var services
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// Called with the new workout's id right after it's saved, so the presenting
|
||||||
|
/// screen can navigate into it once the cache catches up.
|
||||||
|
var onStarted: (String) -> Void = { _ in }
|
||||||
|
|
||||||
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
@Query(sort: [SortDescriptor(\Split.order), SortDescriptor(\Split.name)])
|
||||||
private var splits: [Split]
|
private var splits: [Split]
|
||||||
|
|
||||||
@@ -237,10 +249,64 @@ struct SplitPickerSheet: View {
|
|||||||
logs: logs
|
logs: logs
|
||||||
)
|
)
|
||||||
|
|
||||||
Task { await sync.save(workout: doc) }
|
// Hand the id back after the save so the presenter can poll the cache and
|
||||||
|
// navigate into the run — mirroring the exercise-list start path.
|
||||||
|
Task {
|
||||||
|
await sync.save(workout: doc)
|
||||||
|
onStarted(doc.id)
|
||||||
|
}
|
||||||
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
// Bring the Apple Watch up into the session so the user can run it from the wrist,
|
||||||
// tagged with the split's activity type so the saved Health workout is categorized.
|
// tagged with the split's activity type so the saved Health workout is categorized.
|
||||||
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
services.workoutLauncher.launchWatchWorkout(activityType: split.activityTypeEnum.hkActivityType)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Started-Workout Navigation
|
||||||
|
|
||||||
|
/// Drives a programmatic push into a freshly-started workout's log screen. Both start
|
||||||
|
/// paths — the split picker sheet and a split's exercise list — mint a `WorkoutDocument`,
|
||||||
|
/// write it, then hand its id here; the workout row only materializes once the
|
||||||
|
/// file→observer→cache loop round-trips, so we poll the cache for the entity and push it
|
||||||
|
/// the moment it lands.
|
||||||
|
private struct StartedWorkoutNavigator: ViewModifier {
|
||||||
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Binding var pendingWorkoutID: String?
|
||||||
|
@State private var resolvedWorkout: Workout?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.navigationDestination(item: $resolvedWorkout) { workout in
|
||||||
|
WorkoutLogListView(workout: workout)
|
||||||
|
}
|
||||||
|
.onChange(of: pendingWorkoutID) { _, id in
|
||||||
|
guard let id else { return }
|
||||||
|
pollForWorkout(id: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Poll for the entity after we write the document (the file→observer→cache loop
|
||||||
|
/// typically completes in well under a second). Clear the pending id once it
|
||||||
|
/// resolves, or silently after ~3 s if it never arrives.
|
||||||
|
private func pollForWorkout(id: String) {
|
||||||
|
Task {
|
||||||
|
for _ in 0..<20 {
|
||||||
|
try? await Task.sleep(for: .milliseconds(150))
|
||||||
|
if let workout = CacheMapper.fetchWorkout(id: id, in: modelContext) {
|
||||||
|
resolvedWorkout = workout
|
||||||
|
pendingWorkoutID = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingWorkoutID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
/// Navigate into a workout's log screen once it appears in the cache after being
|
||||||
|
/// started. Bind to the state you set to the new workout's id right after saving it.
|
||||||
|
func navigatesToStartedWorkout(pendingWorkoutID: Binding<String?>) -> some View {
|
||||||
|
modifier(StartedWorkoutNavigator(pendingWorkoutID: pendingWorkoutID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,31 +150,69 @@ struct MetricTile: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high).
|
/// Proportional stacked bar of time spent in each of the 5 heart-rate zones (low→high),
|
||||||
|
/// with a numbered legend so the zones read without relying on color, and a single
|
||||||
|
/// spoken summary of time per zone for VoiceOver.
|
||||||
struct HRZoneBar: View {
|
struct HRZoneBar: View {
|
||||||
let zoneSeconds: [Double]
|
let zoneSeconds: [Double]
|
||||||
|
|
||||||
private let colors: [Color] = [.blue, .green, .yellow, .orange, .red]
|
private let colors: [Color] = [.blue, .green, .yellow, .orange, .red]
|
||||||
|
|
||||||
|
/// Indices of the zones that actually accrued time — the only ones drawn and listed.
|
||||||
|
private var activeZones: [Int] {
|
||||||
|
(0..<min(zoneSeconds.count, colors.count)).filter { zoneSeconds[$0] > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let total = max(zoneSeconds.reduce(0, +), 1)
|
let total = max(zoneSeconds.reduce(0, +), 1)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("Heart Rate Zones")
|
Text("Heart Rate Zones")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
HStack(spacing: 2) {
|
HStack(spacing: 2) {
|
||||||
ForEach(0..<5, id: \.self) { zone in
|
ForEach(activeZones, id: \.self) { zone in
|
||||||
if zoneSeconds[zone] > 0 {
|
colors[zone]
|
||||||
colors[zone]
|
.frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total)))
|
||||||
.frame(width: max(2, geo.size.width * (zoneSeconds[zone] / total)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 12)
|
.frame(height: 12)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
// Numbered legend — each active zone's swatch beside its number, so the
|
||||||
|
// split reads for color-blind (and all) sighted users, not by hue alone.
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(activeZones, id: \.self) { zone in
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
RoundedRectangle(cornerRadius: 3)
|
||||||
|
.fill(colors[zone])
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
Text("Zone \(zone + 1)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
// Collapse the whole bar+legend into one element with a spoken per-zone breakdown.
|
||||||
|
.accessibilityElement(children: .ignore)
|
||||||
|
.accessibilityLabel("Heart rate zones")
|
||||||
|
.accessibilityValue(spokenSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Zone 1, 2 min 30 sec. Zone 2, 5 min." — time in each zone that saw activity.
|
||||||
|
private var spokenSummary: String {
|
||||||
|
activeZones
|
||||||
|
.map { "Zone \($0 + 1), \(spokenDuration(Int(zoneSeconds[$0].rounded())))" }
|
||||||
|
.joined(separator: ". ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func spokenDuration(_ seconds: Int) -> String {
|
||||||
|
let m = seconds / 60, s = seconds % 60
|
||||||
|
if m > 0 && s > 0 { return "\(m) min \(s) sec" }
|
||||||
|
if m > 0 { return "\(m) min" }
|
||||||
|
return "\(s) sec"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user