Merge feat/cooldown-quick-actions: cooldown quick action buttons

When cooldowns reach Ready, cells become clickable buttons opening
the corresponding Torn Items page. Booster target configurable
in Settings (Boosters/Alcohol).
This commit is contained in:
Paweł Orzech 2026-03-14 22:59:20 +01:00
commit c1625ffd57
No known key found for this signature in database
2 changed files with 121 additions and 21 deletions

View file

@ -5,6 +5,7 @@ struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue @AppStorage("appearanceMode") private var appearanceMode: String = AppearanceMode.system.rawValue
@AppStorage("reduceTransparency") private var reduceTransparency: Bool = false @AppStorage("reduceTransparency") private var reduceTransparency: Bool = false
@AppStorage("preferredBrowser") private var preferredBrowser: String = PreferredBrowser.system.rawValue @AppStorage("preferredBrowser") private var preferredBrowser: String = PreferredBrowser.system.rawValue
@AppStorage("boosterCooldownTarget") private var boosterCooldownTarget: String = "boosters"
@State private var inputKey: String = "" @State private var inputKey: String = ""
@State private var showCredits: Bool = false @State private var showCredits: Bool = false
@State private var availableBrowsers: [PreferredBrowser] = PreferredBrowser.availableBrowsers() @State private var availableBrowsers: [PreferredBrowser] = PreferredBrowser.availableBrowsers()
@ -129,6 +130,19 @@ struct SettingsView: View {
Toggle("Reduce Transparency", isOn: $reduceTransparency) Toggle("Reduce Transparency", isOn: $reduceTransparency)
.toggleStyle(.switch) .toggleStyle(.switch)
} }
// Booster Cooldown Target
HStack {
Image(systemName: "arrow.up.circle.fill")
.foregroundColor(.secondary)
.frame(width: 20)
Picker("Booster cooldown link", selection: $boosterCooldownTarget) {
Text("Boosters").tag("boosters")
Text("Alcohol").tag("alcohol")
}
.pickerStyle(.segmented)
}
} }
.padding(.horizontal) .padding(.horizontal)

View file

@ -3,6 +3,7 @@ import SwiftUI
struct StatusView: View { struct StatusView: View {
@EnvironmentObject var appState: AppState @EnvironmentObject var appState: AppState
@Environment(\.reduceTransparency) private var reduceTransparency @Environment(\.reduceTransparency) private var reduceTransparency
@AppStorage("boosterCooldownTarget") private var boosterCooldownTarget: String = "boosters"
var body: some View { var body: some View {
ScrollView { ScrollView {
@ -195,7 +196,14 @@ struct StatusView: View {
// MARK: - Cooldowns // MARK: - Cooldowns
private func cooldownsSection(_ cooldowns: Cooldowns) -> some View { private func cooldownsSection(_ cooldowns: Cooldowns) -> some View {
VStack(alignment: .leading, spacing: 8) { let drugURL = URL(string: "https://www.torn.com/item.php#drugs-items")
let medicalURL = URL(string: "https://www.torn.com/item.php#medical-items")
let boosterURL = boosterCooldownTarget == "alcohol"
? URL(string: "https://www.torn.com/item.php#alcohol-items")
: URL(string: "https://www.torn.com/item.php#boosters-items")
let boosterLabel = boosterCooldownTarget == "alcohol" ? "Use Alcohol →" : "Use Booster →"
return VStack(alignment: .leading, spacing: 8) {
Divider() Divider()
Text("Cooldowns") Text("Cooldowns")
@ -204,13 +212,13 @@ struct StatusView: View {
HStack(spacing: 16) { HStack(spacing: 16) {
if let fetchTime = appState.lastUpdated { if let fetchTime = appState.lastUpdated {
LiveCooldownItem(label: "Drug", originalSeconds: cooldowns.drug, fetchTime: fetchTime, icon: "pills.fill") LiveCooldownItem(label: "Drug", originalSeconds: cooldowns.drug, fetchTime: fetchTime, icon: "pills.fill", actionURL: drugURL, actionLabel: "Use Drug →")
LiveCooldownItem(label: "Medical", originalSeconds: cooldowns.medical, fetchTime: fetchTime, icon: "cross.case.fill") LiveCooldownItem(label: "Medical", originalSeconds: cooldowns.medical, fetchTime: fetchTime, icon: "cross.case.fill", actionURL: medicalURL, actionLabel: "Use Medical →")
LiveCooldownItem(label: "Booster", originalSeconds: cooldowns.booster, fetchTime: fetchTime, icon: "arrow.up.circle.fill") LiveCooldownItem(label: "Booster", originalSeconds: cooldowns.booster, fetchTime: fetchTime, icon: "arrow.up.circle.fill", actionURL: boosterURL, actionLabel: boosterLabel)
} else { } else {
CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill") CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill", actionURL: drugURL, actionLabel: "Use Drug →")
CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill") CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill", actionURL: medicalURL, actionLabel: "Use Medical →")
CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill") CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill", actionURL: boosterURL, actionLabel: boosterLabel)
} }
} }
} }
@ -268,19 +276,58 @@ struct CooldownItem: View {
let label: String let label: String
let seconds: Int let seconds: Int
let icon: String let icon: String
var actionURL: URL? = nil
var actionLabel: String? = nil
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View { var body: some View {
if seconds <= 0, let url = actionURL {
Button {
BrowserManager.shared.open(url)
} label: {
cellContent(remaining: seconds)
}
.buttonStyle(.plain)
.accessibilityLabel("Use \(label)")
.accessibilityHint("Opens Torn items page in browser")
} else {
cellContent(remaining: seconds)
}
}
private func cellContent(remaining: Int) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text(label) Text(label)
.font(.caption) .font(.caption)
.foregroundColor(seconds > 0 ? .orange : .green) .foregroundColor(remaining > 0 ? .orange : .green)
Text(formattedTime) Text(formattedTime)
.font(.caption2.monospacedDigit()) .font(.caption2.monospacedDigit())
.foregroundColor(seconds > 0 ? .primary : .green) .foregroundColor(remaining > 0 ? .primary : .green)
.fontWeight(seconds <= 0 ? .bold : .regular) .fontWeight(remaining <= 0 ? .bold : .regular)
if remaining <= 0, let actionLabel {
Text(actionLabel)
.font(.caption2)
.foregroundColor(.green)
.opacity(0.7)
}
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, remaining <= 0 && actionURL != nil ? 4 : 0)
.background(
remaining <= 0 && actionURL != nil
? Color.green.opacity(reduceTransparency ? 0.25 : 0.12)
: Color.clear
)
.overlay {
if remaining <= 0 && actionURL != nil {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.green.opacity(reduceTransparency ? 0.4 : 0.25))
}
}
.cornerRadius(6)
} }
private var formattedTime: String { private var formattedTime: String {
@ -301,26 +348,65 @@ struct LiveCooldownItem: View {
let originalSeconds: Int let originalSeconds: Int
let fetchTime: Date let fetchTime: Date
let icon: String let icon: String
var actionURL: URL? = nil
var actionLabel: String? = nil
@Environment(\.reduceTransparency) private var reduceTransparency
var body: some View { var body: some View {
TimelineView(.periodic(from: fetchTime, by: 1.0)) { context in TimelineView(.periodic(from: fetchTime, by: 1.0)) { context in
let elapsed = Int(context.date.timeIntervalSince(fetchTime)) let elapsed = Int(context.date.timeIntervalSince(fetchTime))
let remaining = max(0, originalSeconds - elapsed) let remaining = max(0, originalSeconds - elapsed)
VStack(spacing: 2) { if remaining <= 0, let url = actionURL {
Text(label) Button {
.font(.caption) BrowserManager.shared.open(url)
.foregroundColor(remaining > 0 ? .orange : .green) } label: {
cellContent(remaining: remaining)
Text(formattedTime(remaining)) }
.font(.caption2.monospacedDigit()) .buttonStyle(.plain)
.foregroundColor(remaining > 0 ? .primary : .green) .accessibilityLabel("Use \(label)")
.fontWeight(remaining <= 0 ? .bold : .regular) .accessibilityHint("Opens Torn items page in browser")
} else {
cellContent(remaining: remaining)
} }
.frame(maxWidth: .infinity)
} }
} }
private func cellContent(remaining: Int) -> some View {
VStack(spacing: 2) {
Text(label)
.font(.caption)
.foregroundColor(remaining > 0 ? .orange : .green)
Text(formattedTime(remaining))
.font(.caption2.monospacedDigit())
.foregroundColor(remaining > 0 ? .primary : .green)
.fontWeight(remaining <= 0 ? .bold : .regular)
if remaining <= 0, let actionLabel {
Text(actionLabel)
.font(.caption2)
.foregroundColor(.green)
.opacity(0.7)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, remaining <= 0 && actionURL != nil ? 4 : 0)
.background(
remaining <= 0 && actionURL != nil
? Color.green.opacity(reduceTransparency ? 0.25 : 0.12)
: Color.clear
)
.overlay {
if remaining <= 0 && actionURL != nil {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.green.opacity(reduceTransparency ? 0.4 : 0.25))
}
}
.cornerRadius(6)
}
private func formattedTime(_ seconds: Int) -> String { private func formattedTime(_ seconds: Int) -> String {
if seconds <= 0 { return "Ready" } if seconds <= 0 { return "Ready" }
let hours = seconds / 3600 let hours = seconds / 3600