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:
Paweł Orzech 2026-01-17 18:00:47 +00:00
parent 0a0f109fa1
commit 455f9f3916
No known key found for this signature in database
8 changed files with 486 additions and 135 deletions

View file

@ -16,6 +16,8 @@
AAA00007 /* StatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10007 /* StatusView.swift */; };
AAA00008 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10008 /* ProgressBarView.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 */
/* Begin PBXFileReference section */
@ -29,6 +31,8 @@
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>"; };
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; };
/* End PBXFileReference section */
@ -112,6 +116,8 @@
isa = PBXGroup;
children = (
AAA10009 /* NotificationManager.swift */,
AAA10011 /* LaunchAtLoginManager.swift */,
AAA10012 /* ShortcutsManager.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -193,6 +199,8 @@
AAA00007 /* StatusView.swift in Sources */,
AAA00008 /* ProgressBarView.swift in Sources */,
AAA00009 /* NotificationManager.swift in Sources */,
AAA00010 /* LaunchAtLoginManager.swift in Sources */,
AAA00011 /* ShortcutsManager.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View file

@ -16,14 +16,29 @@ struct MacTornApp: App {
}
private var menuBarIcon: String {
// Error state
if appState.errorMsg != nil {
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 bars.energy.current >= bars.energy.maximum {
return "bolt.fill"
}
}
// Default
return "bolt"
}
}

View file

@ -13,9 +13,14 @@ class AppState: ObservableObject {
@Published var errorMsg: String?
@Published var isLoading: Bool = false
// MARK: - Managers
let launchAtLogin = LaunchAtLoginManager()
let shortcutsManager = ShortcutsManager()
// MARK: - State Comparison
private var previousBars: Bars?
private var previousCooldowns: Cooldowns?
private var previousTravel: Travel?
// MARK: - Timer
private var timerCancellable: AnyCancellable?
@ -88,6 +93,7 @@ class AppState: ObservableObject {
// Store for comparison
self.previousBars = decoded.bars
self.previousCooldowns = decoded.cooldowns
self.previousTravel = decoded.travel
}
case 403, 404:
self.errorMsg = "Invalid API Key"
@ -104,8 +110,8 @@ class AppState: ObservableObject {
}
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
if prev.energy.current < prev.energy.maximum &&
current.energy.current >= current.energy.maximum {
@ -123,6 +129,7 @@ class AppState: ObservableObject {
body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))"
)
}
}
// Cooldown notifications
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
@ -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)"
)
}
}
}
}

View file

@ -9,7 +9,11 @@ struct ProgressBarView: View {
private var progress: Double {
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 {
@ -17,31 +21,47 @@ struct ProgressBarView: View {
HStack {
Image(systemName: icon)
.foregroundColor(color)
.font(.caption)
.font(.caption.bold())
Text(label)
.font(.caption.bold())
.foregroundColor(.primary)
Spacer()
Text("\(current)/\(maximum)")
.font(.caption.monospacedDigit())
.foregroundColor(.secondary)
.foregroundColor(isFull ? color : .secondary)
.fontWeight(isFull ? .bold : .regular)
}
// Progress bar with visible styling
GeometryReader { geometry in
ZStack(alignment: .leading) {
// Background
// Background track
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
if progress > 0 {
RoundedRectangle(cornerRadius: 4)
.fill(color)
.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 {
VStack(spacing: 16) {
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: "Happy", current: 1000, maximum: 1000, color: .yellow, icon: "face.smiling.fill")
ProgressBarView(label: "Nerve", current: 50, maximum: 50, color: .red, icon: "flame.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()
.frame(width: 280)
.background(Color(NSColor.windowBackgroundColor))
}

View file

@ -2,25 +2,42 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject var appState: AppState
@State private var showSettings = false
var body: some View {
VStack(spacing: 0) {
if appState.apiKey.isEmpty {
if appState.apiKey.isEmpty || showSettings {
SettingsView()
.environmentObject(appState)
} 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()
.environmentObject(appState)
}
Divider()
.padding(.vertical, 8)
.padding(.vertical, 4)
// Footer buttons
HStack {
Button("Settings") {
appState.apiKey = "" // Go back to settings
if !appState.apiKey.isEmpty {
Button(showSettings ? "Back" : "Settings") {
showSettings.toggle()
}
.buttonStyle(.plain)
.foregroundColor(.secondary)
}
Spacer()
@ -34,7 +51,12 @@ struct ContentView: View {
.padding(.horizontal)
.padding(.bottom, 8)
}
.frame(width: 280)
.environmentObject(appState)
.frame(width: 300)
}
private var timeFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.timeStyle = .short
return formatter
}
}

View file

@ -3,12 +3,20 @@ import SwiftUI
struct SettingsView: View {
@EnvironmentObject var appState: AppState
@State private var inputKey: String = ""
@State private var showShortcutsEditor = false
var body: some View {
VStack(spacing: 16) {
// Header
Image(systemName: "bolt.circle.fill")
.font(.system(size: 48))
.foregroundColor(.orange)
.foregroundStyle(
LinearGradient(
colors: [.orange, .yellow],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
Text("MacTorn")
.font(.title2.bold())
@ -17,6 +25,7 @@ struct SettingsView: View {
.font(.caption)
.foregroundColor(.secondary)
// API Key input
SecureField("API Key", text: $inputKey)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
@ -31,6 +40,33 @@ struct SettingsView: View {
Link("Get API Key from Torn",
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
.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()
.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)
}
}
}
}
}

View file

@ -4,8 +4,41 @@ struct StatusView: View {
@EnvironmentObject var appState: AppState
var body: some View {
ScrollView {
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)
@ -25,16 +58,10 @@ struct StatusView: View {
.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 {
// MARK: - Error
private func errorSection(_ error: String) -> some View {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
@ -45,9 +72,36 @@ struct StatusView: View {
.padding(.vertical, 4)
}
// Bars
if let bars = appState.data?.bars {
VStack(spacing: 8) {
// MARK: - Travel
private func travelSection(_ travel: Travel) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "airplane")
.foregroundColor(.blue)
Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)")
.font(.caption.bold())
}
if travel.isTraveling {
HStack {
Text("Arriving in:")
.font(.caption2)
.foregroundColor(.secondary)
Text(formatTime(travel.timeLeft))
.font(.caption.monospacedDigit())
.foregroundColor(.blue)
}
}
}
.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,
@ -82,10 +136,10 @@ struct StatusView: View {
}
}
// Cooldowns
if let cooldowns = appState.data?.cooldowns {
// MARK: - Cooldowns
private func cooldownsSection(_ cooldowns: Cooldowns) -> some View {
VStack(alignment: .leading, spacing: 8) {
Divider()
.padding(.vertical, 4)
Text("Cooldowns")
.font(.caption.bold())
@ -98,7 +152,51 @@ struct StatusView: View {
}
}
}
.padding()
// 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 {
@ -117,11 +215,13 @@ struct CooldownItem: View {
var body: some View {
VStack(spacing: 2) {
Image(systemName: icon)
.font(.caption)
.foregroundColor(seconds > 0 ? .orange : .green)
Text(formattedTime)
.font(.caption2.monospacedDigit())
.foregroundColor(seconds > 0 ? .primary : .green)
.fontWeight(seconds <= 0 ? .bold : .regular)
}
.frame(maxWidth: .infinity)
}
@ -130,12 +230,11 @@ struct CooldownItem: View {
if seconds <= 0 {
return "Ready"
}
let minutes = seconds / 60
let hours = seconds / 3600
let minutes = (seconds % 3600) / 60
let secs = seconds % 60
if minutes >= 60 {
let hours = minutes / 60
let mins = minutes % 60
return String(format: "%d:%02d:%02d", hours, mins, secs)
if hours > 0 {
return String(format: "%d:%02d:%02d", hours, minutes, secs)
}
return String(format: "%d:%02d", minutes, secs)
}

View file

@ -1,2 +1,30 @@
# 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`.