Add duty cycle/utilization fields, wheel picker for goals, and updated screenshots
- Add dutyCyclePercent and defaultUtilizationFactorPercent to ComponentLibraryItem with normalization logic and backend field fetching - Change default dailyUsageHours from 1h to 24h - Replace goal editor stepper with day/hour/minute wheel pickers - Update app icon colors and remove duplicate icon assets - Move SavedBattery.swift into Batteries/ directory, remove Pods group - Add iPad-only flag and start frame support to screenshot framing scripts - Rework localized App Store screenshot titles across all languages - Add runtime goals and BOM completed items to sample data - Bump version to 1.5.1 (build 41) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1437,13 +1437,59 @@ private struct GoalEditorSheet: View {
|
||||
let onSave: (Double) -> Void
|
||||
let onClear: (() -> Void)?
|
||||
|
||||
@State private var days: Int
|
||||
@State private var hours: Int
|
||||
@State private var minutes: Int
|
||||
private let minuteStepValues: [Int]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
init(
|
||||
title: String,
|
||||
tint: Color,
|
||||
value: Binding<Double>,
|
||||
minimum: Double,
|
||||
maximum: Double,
|
||||
step: Double,
|
||||
cancelTitle: String,
|
||||
saveTitle: String,
|
||||
clearTitle: String,
|
||||
showsClear: Bool,
|
||||
formattedDurationProvider: @escaping (Double) -> String,
|
||||
onSave: @escaping (Double) -> Void,
|
||||
onClear: (() -> Void)?
|
||||
) {
|
||||
self.title = title
|
||||
self.tint = tint
|
||||
self._value = value
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.step = step
|
||||
self.cancelTitle = cancelTitle
|
||||
self.saveTitle = saveTitle
|
||||
self.clearTitle = clearTitle
|
||||
self.showsClear = showsClear
|
||||
self.formattedDurationProvider = formattedDurationProvider
|
||||
self.onSave = onSave
|
||||
self.onClear = onClear
|
||||
self.minuteStepValues = GoalEditorSheet.minuteValues(forStep: step)
|
||||
|
||||
let initialComponents = GoalEditorSheet.components(
|
||||
for: value.wrappedValue,
|
||||
minimum: minimum,
|
||||
maximum: maximum,
|
||||
minuteValues: self.minuteStepValues
|
||||
)
|
||||
_days = State(initialValue: initialComponents.days)
|
||||
_hours = State(initialValue: initialComponents.hours)
|
||||
_minutes = State(initialValue: initialComponents.minutes)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(value: $value, in: minimum...maximum, step: step) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Label {
|
||||
Text(formattedDurationProvider(value))
|
||||
.font(.title3.weight(.semibold))
|
||||
@@ -1452,6 +1498,36 @@ private struct GoalEditorSheet: View {
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundStyle(tint)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Picker("Days", selection: $days) {
|
||||
ForEach(0...maxDays, id: \.self) { day in
|
||||
Text("\(day) day\(day == 1 ? "" : "s")")
|
||||
.tag(day)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Picker("Hours", selection: $hours) {
|
||||
ForEach(hourRange, id: \.self) { hour in
|
||||
Text("\(hour) hr\(hour == 1 ? "" : "s")")
|
||||
.tag(hour)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
Picker("Minutes", selection: $minutes) {
|
||||
ForEach(minuteOptionsForSelection, id: \.self) { minute in
|
||||
Text("\(minute) min\(minute == 1 ? "" : "s")")
|
||||
.tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.wheel)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.frame(height: 140)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1484,12 +1560,166 @@ private struct GoalEditorSheet: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: days) { _ in
|
||||
let cappedHours = min(hours, maxHours(for: days))
|
||||
if cappedHours != hours {
|
||||
hours = cappedHours
|
||||
}
|
||||
let allowedMinutes = minuteOptions(for: days, hours: hours)
|
||||
if !allowedMinutes.contains(minutes) {
|
||||
minutes = allowedMinutes.last ?? 0
|
||||
}
|
||||
updateValueFromSelection()
|
||||
}
|
||||
.onChange(of: hours) { _ in
|
||||
let allowedMinutes = minuteOptions(for: days, hours: hours)
|
||||
if !allowedMinutes.contains(minutes) {
|
||||
minutes = allowedMinutes.last ?? 0
|
||||
}
|
||||
updateValueFromSelection()
|
||||
}
|
||||
.onChange(of: minutes) { _ in
|
||||
updateValueFromSelection()
|
||||
}
|
||||
.onChange(of: value) { newValue in
|
||||
syncPickers(with: newValue)
|
||||
}
|
||||
.presentationDetents([.medium])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
||||
private var maxDays: Int {
|
||||
max(0, Int(floor(maximum / 24)))
|
||||
}
|
||||
|
||||
private var hourRange: ClosedRange<Int> {
|
||||
0...maxHours(for: days)
|
||||
}
|
||||
|
||||
private var minuteOptionsForSelection: [Int] {
|
||||
minuteOptions(for: days, hours: hours)
|
||||
}
|
||||
|
||||
private var clampedValue: Double {
|
||||
min(max(value, minimum), maximum)
|
||||
clamp(value)
|
||||
}
|
||||
|
||||
private func maxHours(for days: Int) -> Int {
|
||||
GoalEditorSheet.maxHours(forDays: days, maximum: maximum)
|
||||
}
|
||||
|
||||
private func minuteOptions(for days: Int, hours: Int) -> [Int] {
|
||||
GoalEditorSheet.allowedMinutes(
|
||||
forDays: days,
|
||||
hours: hours,
|
||||
maximum: maximum,
|
||||
minuteValues: minuteStepValues
|
||||
)
|
||||
}
|
||||
|
||||
private func updateValueFromSelection() {
|
||||
let totalHours = Double(days * 24 + hours) + Double(minutes) / 60
|
||||
let clamped = clamp(totalHours)
|
||||
if abs(value - clamped) > .ulpOfOne {
|
||||
value = clamped
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPickers(with newValue: Double) {
|
||||
let clamped = clamp(newValue)
|
||||
let components = GoalEditorSheet.components(
|
||||
for: clamped,
|
||||
minimum: minimum,
|
||||
maximum: maximum,
|
||||
minuteValues: minuteStepValues
|
||||
)
|
||||
if components.days != days {
|
||||
days = components.days
|
||||
}
|
||||
if components.hours != hours {
|
||||
hours = components.hours
|
||||
}
|
||||
if components.minutes != minutes {
|
||||
minutes = components.minutes
|
||||
}
|
||||
}
|
||||
|
||||
private func clamp(_ candidate: Double) -> Double {
|
||||
min(max(candidate, minimum), maximum)
|
||||
}
|
||||
|
||||
private static func components(
|
||||
for value: Double,
|
||||
minimum: Double,
|
||||
maximum: Double,
|
||||
minuteValues: [Int]
|
||||
) -> (days: Int, hours: Int, minutes: Int) {
|
||||
let clamped = min(max(value, minimum), maximum)
|
||||
let totalMinutes = Int((clamped * 60).rounded())
|
||||
let minutesPerDay = 24 * 60
|
||||
let maxDays = max(0, Int(floor(maximum / 24)))
|
||||
let rawDays = totalMinutes / minutesPerDay
|
||||
let days = min(rawDays, maxDays)
|
||||
let remainingAfterDays = totalMinutes - (days * minutesPerDay)
|
||||
let rawHours = remainingAfterDays / 60
|
||||
let maxHours = maxHours(forDays: days, maximum: maximum)
|
||||
let hours = min(rawHours, maxHours)
|
||||
let minuteRemainder = remainingAfterDays - hours * 60
|
||||
let allowedMinutes = allowedMinutes(
|
||||
forDays: days,
|
||||
hours: hours,
|
||||
maximum: maximum,
|
||||
minuteValues: minuteValues
|
||||
)
|
||||
let minutes = closestMinuteValue(
|
||||
target: minuteRemainder,
|
||||
allowedMinutes: allowedMinutes
|
||||
)
|
||||
return (days, hours, minutes)
|
||||
}
|
||||
|
||||
private static func maxHours(forDays days: Int, maximum: Double) -> Int {
|
||||
let remaining = maximum - Double(days * 24)
|
||||
guard remaining > 0 else { return 0 }
|
||||
return min(23, max(0, Int(floor(remaining))))
|
||||
}
|
||||
|
||||
private static func allowedMinutes(
|
||||
forDays days: Int,
|
||||
hours: Int,
|
||||
maximum: Double,
|
||||
minuteValues: [Int]
|
||||
) -> [Int] {
|
||||
guard maximum > 0 else { return [0] }
|
||||
let usedHours = Double(days * 24 + hours)
|
||||
let remaining = maximum - usedHours
|
||||
guard remaining > 0 else { return [0] }
|
||||
|
||||
let allowed = minuteValues.filter { minute in
|
||||
let additional = Double(minute) / 60
|
||||
return usedHours + additional <= maximum + 1e-6
|
||||
}
|
||||
return allowed.isEmpty ? [0] : allowed
|
||||
}
|
||||
|
||||
private static func closestMinuteValue(target: Int, allowedMinutes: [Int]) -> Int {
|
||||
guard !allowedMinutes.isEmpty else { return 0 }
|
||||
let clampedTarget = max(0, min(59, target))
|
||||
return allowedMinutes.min(by: { abs($0 - clampedTarget) < abs($1 - clampedTarget) }) ?? 0
|
||||
}
|
||||
|
||||
private static func minuteValues(forStep step: Double) -> [Int] {
|
||||
guard step > 0 else { return [0, 15, 30, 45] }
|
||||
let increment = max(1, Int(round(step * 60)))
|
||||
guard increment < 60 else { return [0] }
|
||||
|
||||
var values: [Int] = []
|
||||
var current = 0
|
||||
while current < 60 {
|
||||
values.append(current)
|
||||
current += increment
|
||||
}
|
||||
return values.isEmpty ? [0] : values
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user