mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-30 04:04:27 +00:00
Add launch at login, shortcuts, and UI improvements
Introduces LaunchAtLoginManager and ShortcutsManager for launch at login and customizable quick links. Updates SettingsView with toggles and a shortcuts editor, enhances ContentView and StatusView with improved layout, travel status, and quick links grid. ProgressBarView now visually highlights full bars and improves appearance. Adds travel notifications and updates the README with new features and setup instructions.
This commit is contained in:
parent
0a0f109fa1
commit
455f9f3916
8 changed files with 486 additions and 135 deletions
|
|
@ -16,6 +16,8 @@
|
||||||
AAA00007 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10007 /* StatusView.swift */; };
|
AAA00007 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10007 /* StatusView.swift */; };
|
||||||
AAA00008 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10008 /* ProgressBarView.swift */; };
|
AAA00008 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10008 /* ProgressBarView.swift */; };
|
||||||
AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; };
|
AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; };
|
||||||
|
AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; };
|
||||||
|
AAA00011 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10012 /* ShortcutsManager.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -29,6 +31,8 @@
|
||||||
AAA10008 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = "<group>"; };
|
AAA10008 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = "<group>"; };
|
||||||
AAA10009 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
AAA10009 /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||||
AAA10010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
AAA10010 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
AAA10011 /* LaunchAtLoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginManager.swift; sourceTree = "<group>"; };
|
||||||
|
AAA10012 /* ShortcutsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsManager.swift; sourceTree = "<group>"; };
|
||||||
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
|
@ -112,6 +116,8 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AAA10009 /* NotificationManager.swift */,
|
AAA10009 /* NotificationManager.swift */,
|
||||||
|
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||||
|
AAA10012 /* ShortcutsManager.swift */,
|
||||||
);
|
);
|
||||||
path = Utilities;
|
path = Utilities;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -193,6 +199,8 @@
|
||||||
AAA00007 /* StatusView.swift in Sources */,
|
AAA00007 /* StatusView.swift in Sources */,
|
||||||
AAA00008 /* ProgressBarView.swift in Sources */,
|
AAA00008 /* ProgressBarView.swift in Sources */,
|
||||||
AAA00009 /* NotificationManager.swift in Sources */,
|
AAA00009 /* NotificationManager.swift in Sources */,
|
||||||
|
AAA00010 /* LaunchAtLoginManager.swift in Sources */,
|
||||||
|
AAA00011 /* ShortcutsManager.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,29 @@ struct MacTornApp: App {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var menuBarIcon: String {
|
private var menuBarIcon: String {
|
||||||
|
// Error state
|
||||||
if appState.errorMsg != nil {
|
if appState.errorMsg != nil {
|
||||||
return "exclamationmark.triangle.fill"
|
return "exclamationmark.triangle.fill"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Traveling state
|
||||||
|
if let travel = appState.data?.travel, travel.isTraveling {
|
||||||
|
return "airplane"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abroad state
|
||||||
|
if let travel = appState.data?.travel, travel.isAbroad {
|
||||||
|
return "globe"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Energy full state
|
||||||
if let bars = appState.data?.bars {
|
if let bars = appState.data?.bars {
|
||||||
if bars.energy.current >= bars.energy.maximum {
|
if bars.energy.current >= bars.energy.maximum {
|
||||||
return "bolt.fill"
|
return "bolt.fill"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Default
|
||||||
return "bolt"
|
return "bolt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,14 @@ class AppState: ObservableObject {
|
||||||
@Published var errorMsg: String?
|
@Published var errorMsg: String?
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
|
|
||||||
|
// MARK: - Managers
|
||||||
|
let launchAtLogin = LaunchAtLoginManager()
|
||||||
|
let shortcutsManager = ShortcutsManager()
|
||||||
|
|
||||||
// MARK: - State Comparison
|
// MARK: - State Comparison
|
||||||
private var previousBars: Bars?
|
private var previousBars: Bars?
|
||||||
private var previousCooldowns: Cooldowns?
|
private var previousCooldowns: Cooldowns?
|
||||||
|
private var previousTravel: Travel?
|
||||||
|
|
||||||
// MARK: - Timer
|
// MARK: - Timer
|
||||||
private var timerCancellable: AnyCancellable?
|
private var timerCancellable: AnyCancellable?
|
||||||
|
|
@ -88,6 +93,7 @@ class AppState: ObservableObject {
|
||||||
// Store for comparison
|
// Store for comparison
|
||||||
self.previousBars = decoded.bars
|
self.previousBars = decoded.bars
|
||||||
self.previousCooldowns = decoded.cooldowns
|
self.previousCooldowns = decoded.cooldowns
|
||||||
|
self.previousTravel = decoded.travel
|
||||||
}
|
}
|
||||||
case 403, 404:
|
case 403, 404:
|
||||||
self.errorMsg = "Invalid API Key"
|
self.errorMsg = "Invalid API Key"
|
||||||
|
|
@ -104,24 +110,25 @@ class AppState: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkNotifications(newData: TornResponse) {
|
private func checkNotifications(newData: TornResponse) {
|
||||||
guard let prev = previousBars, let current = newData.bars else { return }
|
// Bar notifications
|
||||||
|
if let prev = previousBars, let current = newData.bars {
|
||||||
// Energy full notification
|
// Energy full notification
|
||||||
if prev.energy.current < prev.energy.maximum &&
|
if prev.energy.current < prev.energy.maximum &&
|
||||||
current.energy.current >= current.energy.maximum {
|
current.energy.current >= current.energy.maximum {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(
|
||||||
title: "Energy Full! ⚡️",
|
title: "Energy Full! ⚡️",
|
||||||
body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))"
|
body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nerve full notification
|
// Nerve full notification
|
||||||
if prev.nerve.current < prev.nerve.maximum &&
|
if prev.nerve.current < prev.nerve.maximum &&
|
||||||
current.nerve.current >= current.nerve.maximum {
|
current.nerve.current >= current.nerve.maximum {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(
|
||||||
title: "Nerve Full! 💪",
|
title: "Nerve Full! 💪",
|
||||||
body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))"
|
body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))"
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown notifications
|
// Cooldown notifications
|
||||||
|
|
@ -145,6 +152,17 @@ class AppState: ObservableObject {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Travel notifications
|
||||||
|
if let prevTravel = previousTravel, let currentTravel = newData.travel {
|
||||||
|
// Landed notification
|
||||||
|
if prevTravel.isTraveling && !currentTravel.isTraveling {
|
||||||
|
NotificationManager.shared.send(
|
||||||
|
title: "Landed! ✈️",
|
||||||
|
body: "You have arrived in \(currentTravel.destination)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,11 @@ struct ProgressBarView: View {
|
||||||
|
|
||||||
private var progress: Double {
|
private var progress: Double {
|
||||||
guard maximum > 0 else { return 0 }
|
guard maximum > 0 else { return 0 }
|
||||||
return Double(current) / Double(maximum)
|
return min(1.0, Double(current) / Double(maximum))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isFull: Bool {
|
||||||
|
current >= maximum
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -17,31 +21,47 @@ struct ProgressBarView: View {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.foregroundColor(color)
|
.foregroundColor(color)
|
||||||
.font(.caption)
|
.font(.caption.bold())
|
||||||
|
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Text("\(current)/\(maximum)")
|
Text("\(current)/\(maximum)")
|
||||||
.font(.caption.monospacedDigit())
|
.font(.caption.monospacedDigit())
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(isFull ? color : .secondary)
|
||||||
|
.fontWeight(isFull ? .bold : .regular)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Progress bar with visible styling
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ZStack(alignment: .leading) {
|
ZStack(alignment: .leading) {
|
||||||
// Background
|
// Background track
|
||||||
RoundedRectangle(cornerRadius: 4)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.fill(color.opacity(0.2))
|
.fill(Color.gray.opacity(0.3))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.stroke(color.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
// Foreground
|
// Filled progress
|
||||||
RoundedRectangle(cornerRadius: 4)
|
if progress > 0 {
|
||||||
.fill(color)
|
RoundedRectangle(cornerRadius: 4)
|
||||||
.frame(width: geometry.size.width * progress)
|
.fill(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [color, color.opacity(0.7)],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.frame(width: max(4, geometry.size.width * progress))
|
||||||
|
.shadow(color: color.opacity(0.5), radius: 2, x: 0, y: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: 8)
|
.frame(height: 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -49,9 +69,11 @@ struct ProgressBarView: View {
|
||||||
#Preview {
|
#Preview {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ProgressBarView(label: "Energy", current: 75, maximum: 100, color: .green, icon: "bolt.fill")
|
ProgressBarView(label: "Energy", current: 75, maximum: 100, color: .green, icon: "bolt.fill")
|
||||||
ProgressBarView(label: "Nerve", current: 25, maximum: 50, color: .red, icon: "flame.fill")
|
ProgressBarView(label: "Nerve", current: 50, maximum: 50, color: .red, icon: "flame.fill")
|
||||||
ProgressBarView(label: "Happy", current: 1000, maximum: 1000, color: .yellow, icon: "face.smiling.fill")
|
ProgressBarView(label: "Happy", current: 500, maximum: 1000, color: .yellow, icon: "face.smiling.fill")
|
||||||
|
ProgressBarView(label: "Life", current: 0, maximum: 100, color: .pink, icon: "heart.fill")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.frame(width: 280)
|
.frame(width: 280)
|
||||||
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,42 @@ import SwiftUI
|
||||||
|
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State private var showSettings = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
if appState.apiKey.isEmpty {
|
if appState.apiKey.isEmpty || showSettings {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
|
.environmentObject(appState)
|
||||||
} else {
|
} else {
|
||||||
|
// Last updated
|
||||||
|
if let lastUpdated = appState.lastUpdated {
|
||||||
|
HStack {
|
||||||
|
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
StatusView()
|
StatusView()
|
||||||
|
.environmentObject(appState)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
// Footer buttons
|
// Footer buttons
|
||||||
HStack {
|
HStack {
|
||||||
Button("Settings") {
|
if !appState.apiKey.isEmpty {
|
||||||
appState.apiKey = "" // Go back to settings
|
Button(showSettings ? "Back" : "Settings") {
|
||||||
|
showSettings.toggle()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|
@ -34,7 +51,12 @@ struct ContentView: View {
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
.padding(.bottom, 8)
|
.padding(.bottom, 8)
|
||||||
}
|
}
|
||||||
.frame(width: 280)
|
.frame(width: 300)
|
||||||
.environmentObject(appState)
|
}
|
||||||
|
|
||||||
|
private var timeFormatter: DateFormatter {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,20 @@ import SwiftUI
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
@State private var inputKey: String = ""
|
@State private var inputKey: String = ""
|
||||||
|
@State private var showShortcutsEditor = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
|
// Header
|
||||||
Image(systemName: "bolt.circle.fill")
|
Image(systemName: "bolt.circle.fill")
|
||||||
.font(.system(size: 48))
|
.font(.system(size: 48))
|
||||||
.foregroundColor(.orange)
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.orange, .yellow],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
Text("MacTorn")
|
Text("MacTorn")
|
||||||
.font(.title2.bold())
|
.font(.title2.bold())
|
||||||
|
|
@ -17,6 +25,7 @@ struct SettingsView: View {
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
// API Key input
|
||||||
SecureField("API Key", text: $inputKey)
|
SecureField("API Key", text: $inputKey)
|
||||||
.textFieldStyle(.roundedBorder)
|
.textFieldStyle(.roundedBorder)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
@ -31,6 +40,33 @@ struct SettingsView: View {
|
||||||
Link("Get API Key from Torn",
|
Link("Get API Key from Torn",
|
||||||
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
|
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Launch at Login
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { appState.launchAtLogin.isEnabled },
|
||||||
|
set: { _ in appState.launchAtLogin.toggle() }
|
||||||
|
)) {
|
||||||
|
Label("Launch at Login", systemImage: "power")
|
||||||
|
}
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// Shortcuts Editor
|
||||||
|
Button {
|
||||||
|
showShortcutsEditor.toggle()
|
||||||
|
} label: {
|
||||||
|
Label("Edit Shortcuts", systemImage: "keyboard")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
|
||||||
|
if showShortcutsEditor {
|
||||||
|
ShortcutsEditorView()
|
||||||
|
.environmentObject(appState)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -38,3 +74,106 @@ struct SettingsView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Shortcuts Editor
|
||||||
|
struct ShortcutsEditorView: View {
|
||||||
|
@EnvironmentObject var appState: AppState
|
||||||
|
@State private var editingShortcut: KeyboardShortcut?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Quick Links")
|
||||||
|
.font(.caption.bold())
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Reset") {
|
||||||
|
appState.shortcutsManager.resetToDefaults()
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.red)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(appState.shortcutsManager.shortcuts) { shortcut in
|
||||||
|
ShortcutRowView(shortcut: shortcut) { updated in
|
||||||
|
appState.shortcutsManager.updateShortcut(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(Color.gray.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ShortcutRowView: View {
|
||||||
|
let shortcut: KeyboardShortcut
|
||||||
|
let onUpdate: (KeyboardShortcut) -> Void
|
||||||
|
|
||||||
|
@State private var isEditing = false
|
||||||
|
@State private var editedName: String = ""
|
||||||
|
@State private var editedURL: String = ""
|
||||||
|
@State private var editedKey: String = ""
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
if isEditing {
|
||||||
|
TextField("Name", text: $editedName)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 60)
|
||||||
|
|
||||||
|
TextField("URL", text: $editedURL)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.caption2)
|
||||||
|
|
||||||
|
TextField("Key", text: $editedKey)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 30)
|
||||||
|
|
||||||
|
Button("Save") {
|
||||||
|
var updated = shortcut
|
||||||
|
updated.name = editedName
|
||||||
|
updated.url = editedURL
|
||||||
|
updated.keyEquivalent = editedKey
|
||||||
|
onUpdate(updated)
|
||||||
|
isEditing = false
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
} else {
|
||||||
|
Text(shortcut.name)
|
||||||
|
.font(.caption)
|
||||||
|
.frame(width: 60, alignment: .leading)
|
||||||
|
|
||||||
|
Text(shortcut.url)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text("⌘⇧\(shortcut.keyEquivalent.uppercased())")
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
editedName = shortcut.name
|
||||||
|
editedURL = shortcut.url
|
||||||
|
editedKey = shortcut.keyEquivalent
|
||||||
|
isEditing = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "pencil")
|
||||||
|
.font(.caption2)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,101 +4,199 @@ struct StatusView: View {
|
||||||
@EnvironmentObject var appState: AppState
|
@EnvironmentObject var appState: AppState
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
ScrollView {
|
||||||
// Header
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
// Header
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if let error = appState.errorMsg {
|
||||||
|
errorSection(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel status
|
||||||
|
if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad {
|
||||||
|
travelSection(travel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bars
|
||||||
|
if let bars = appState.data?.bars {
|
||||||
|
barsSection(bars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldowns
|
||||||
|
if let cooldowns = appState.data?.cooldowns {
|
||||||
|
cooldownsSection(cooldowns)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick Links
|
||||||
|
quickLinksSection
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(maxHeight: 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
private var headerSection: some View {
|
||||||
|
HStack {
|
||||||
|
Text("Torn Status")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if appState.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.6)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
appState.refreshNow()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error
|
||||||
|
private func errorSection(_ error: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel
|
||||||
|
private func travelSection(_ travel: Travel) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Torn Status")
|
Image(systemName: "airplane")
|
||||||
.font(.headline)
|
.foregroundColor(.blue)
|
||||||
|
Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)")
|
||||||
Spacer()
|
|
||||||
|
|
||||||
if appState.isLoading {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.6)
|
|
||||||
} else {
|
|
||||||
Button {
|
|
||||||
appState.refreshNow()
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "arrow.clockwise")
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last updated
|
|
||||||
if let lastUpdated = appState.lastUpdated {
|
|
||||||
Text("Updated: \(lastUpdated, formatter: timeFormatter)")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if let error = appState.errorMsg {
|
|
||||||
HStack {
|
|
||||||
Image(systemName: "exclamationmark.triangle.fill")
|
|
||||||
.foregroundColor(.orange)
|
|
||||||
Text(error)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bars
|
|
||||||
if let bars = appState.data?.bars {
|
|
||||||
VStack(spacing: 8) {
|
|
||||||
ProgressBarView(
|
|
||||||
label: "Energy",
|
|
||||||
current: bars.energy.current,
|
|
||||||
maximum: bars.energy.maximum,
|
|
||||||
color: .green,
|
|
||||||
icon: "bolt.fill"
|
|
||||||
)
|
|
||||||
|
|
||||||
ProgressBarView(
|
|
||||||
label: "Nerve",
|
|
||||||
current: bars.nerve.current,
|
|
||||||
maximum: bars.nerve.maximum,
|
|
||||||
color: .red,
|
|
||||||
icon: "flame.fill"
|
|
||||||
)
|
|
||||||
|
|
||||||
ProgressBarView(
|
|
||||||
label: "Happy",
|
|
||||||
current: bars.happy.current,
|
|
||||||
maximum: bars.happy.maximum,
|
|
||||||
color: .yellow,
|
|
||||||
icon: "face.smiling.fill"
|
|
||||||
)
|
|
||||||
|
|
||||||
ProgressBarView(
|
|
||||||
label: "Life",
|
|
||||||
current: bars.life.current,
|
|
||||||
maximum: bars.life.maximum,
|
|
||||||
color: .pink,
|
|
||||||
icon: "heart.fill"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cooldowns
|
|
||||||
if let cooldowns = appState.data?.cooldowns {
|
|
||||||
Divider()
|
|
||||||
.padding(.vertical, 4)
|
|
||||||
|
|
||||||
Text("Cooldowns")
|
|
||||||
.font(.caption.bold())
|
.font(.caption.bold())
|
||||||
.foregroundColor(.secondary)
|
}
|
||||||
|
|
||||||
HStack(spacing: 16) {
|
if travel.isTraveling {
|
||||||
CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill")
|
HStack {
|
||||||
CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill")
|
Text("Arriving in:")
|
||||||
CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill")
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text(formatTime(travel.timeLeft))
|
||||||
|
.font(.caption.monospacedDigit())
|
||||||
|
.foregroundColor(.blue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bars
|
||||||
|
private func barsSection(_ bars: Bars) -> some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ProgressBarView(
|
||||||
|
label: "Energy",
|
||||||
|
current: bars.energy.current,
|
||||||
|
maximum: bars.energy.maximum,
|
||||||
|
color: .green,
|
||||||
|
icon: "bolt.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
ProgressBarView(
|
||||||
|
label: "Nerve",
|
||||||
|
current: bars.nerve.current,
|
||||||
|
maximum: bars.nerve.maximum,
|
||||||
|
color: .red,
|
||||||
|
icon: "flame.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
ProgressBarView(
|
||||||
|
label: "Happy",
|
||||||
|
current: bars.happy.current,
|
||||||
|
maximum: bars.happy.maximum,
|
||||||
|
color: .yellow,
|
||||||
|
icon: "face.smiling.fill"
|
||||||
|
)
|
||||||
|
|
||||||
|
ProgressBarView(
|
||||||
|
label: "Life",
|
||||||
|
current: bars.life.current,
|
||||||
|
maximum: bars.life.maximum,
|
||||||
|
color: .pink,
|
||||||
|
icon: "heart.fill"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Cooldowns
|
||||||
|
private func cooldownsSection(_ cooldowns: Cooldowns) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Cooldowns")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
CooldownItem(label: "Drug", seconds: cooldowns.drug, icon: "pills.fill")
|
||||||
|
CooldownItem(label: "Medical", seconds: cooldowns.medical, icon: "cross.case.fill")
|
||||||
|
CooldownItem(label: "Booster", seconds: cooldowns.booster, icon: "arrow.up.circle.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Quick Links
|
||||||
|
private var quickLinksSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Text("Quick Links")
|
||||||
|
.font(.caption.bold())
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible()),
|
||||||
|
GridItem(.flexible())
|
||||||
|
], spacing: 8) {
|
||||||
|
ForEach(appState.shortcutsManager.shortcuts) { shortcut in
|
||||||
|
Button {
|
||||||
|
appState.shortcutsManager.openURL(shortcut.url)
|
||||||
|
} label: {
|
||||||
|
Text(shortcut.name)
|
||||||
|
.font(.caption2)
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.padding(.horizontal, 6)
|
||||||
|
.background(Color.accentColor.opacity(0.1))
|
||||||
|
.cornerRadius(4)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Helpers
|
||||||
|
private func formatTime(_ seconds: Int) -> String {
|
||||||
|
if seconds <= 0 { return "Ready" }
|
||||||
|
let hours = seconds / 3600
|
||||||
|
let minutes = (seconds % 3600) / 60
|
||||||
|
let secs = seconds % 60
|
||||||
|
if hours > 0 {
|
||||||
|
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||||
|
}
|
||||||
|
return String(format: "%d:%02d", minutes, secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeFormatter: DateFormatter {
|
private var timeFormatter: DateFormatter {
|
||||||
|
|
@ -117,11 +215,13 @@ struct CooldownItem: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 2) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
|
.font(.caption)
|
||||||
.foregroundColor(seconds > 0 ? .orange : .green)
|
.foregroundColor(seconds > 0 ? .orange : .green)
|
||||||
|
|
||||||
Text(formattedTime)
|
Text(formattedTime)
|
||||||
.font(.caption2.monospacedDigit())
|
.font(.caption2.monospacedDigit())
|
||||||
.foregroundColor(seconds > 0 ? .primary : .green)
|
.foregroundColor(seconds > 0 ? .primary : .green)
|
||||||
|
.fontWeight(seconds <= 0 ? .bold : .regular)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
@ -130,12 +230,11 @@ struct CooldownItem: View {
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
return "Ready"
|
return "Ready"
|
||||||
}
|
}
|
||||||
let minutes = seconds / 60
|
let hours = seconds / 3600
|
||||||
|
let minutes = (seconds % 3600) / 60
|
||||||
let secs = seconds % 60
|
let secs = seconds % 60
|
||||||
if minutes >= 60 {
|
if hours > 0 {
|
||||||
let hours = minutes / 60
|
return String(format: "%d:%02d:%02d", hours, minutes, secs)
|
||||||
let mins = minutes % 60
|
|
||||||
return String(format: "%d:%02d:%02d", hours, mins, secs)
|
|
||||||
}
|
}
|
||||||
return String(format: "%d:%02d", minutes, secs)
|
return String(format: "%d:%02d", minutes, secs)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
30
README.md
30
README.md
|
|
@ -1,2 +1,30 @@
|
||||||
# MacTorn
|
# MacTorn
|
||||||
Torn notifier app for macOS
|
Menu bar companion for the Torn game on macOS. It polls the Torn API and surfaces your current bars, cooldowns, and travel status in a lightweight menu bar window.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Menu bar status window with energy, nerve, happy, and life bars.
|
||||||
|
- Cooldown timers for drug, medical, and booster.
|
||||||
|
- Travel status with remaining time and destination.
|
||||||
|
- Local notifications when bars fill, cooldowns end, or you land.
|
||||||
|
- Quick links grid for Torn pages with editable labels and URLs.
|
||||||
|
- Launch at login toggle.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- macOS 13.0 or later.
|
||||||
|
- A Torn API key.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. Open the app and paste your Torn API key.
|
||||||
|
2. Click "Save & Connect".
|
||||||
|
3. (Optional) Enable "Launch at Login" and edit Quick Links.
|
||||||
|
|
||||||
|
Get an API key from `https://www.torn.com/preferences.php#tab=api`.
|
||||||
|
|
||||||
|
## Build and Run
|
||||||
|
1. Open `MacTorn/MacTorn.xcodeproj` in Xcode.
|
||||||
|
2. Select the MacTorn scheme.
|
||||||
|
3. Run the app (it appears in the menu bar).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- The app polls the Torn API every 30 seconds.
|
||||||
|
- Your API key and Quick Links are stored locally in `UserDefaults`.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue