mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-29 19:54:27 +00:00
Add notification rules, status badges, and chain support
Introduces customizable notification rules with sound options and a UI for managing them in SettingsView. Adds support for displaying chain status, hospital/jail badges, unread messages, and recent events in StatusView. Makes the refresh interval configurable. Updates README with new features and installation instructions. Updates Xcode project to include new components and utilities.
This commit is contained in:
parent
01b0b9a436
commit
53a234afcd
6 changed files with 366 additions and 121 deletions
BIN
MacTorn-v1.0.zip
Normal file
BIN
MacTorn-v1.0.zip
Normal file
Binary file not shown.
|
|
@ -18,6 +18,10 @@
|
|||
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 */; };
|
||||
AAA00012 /* ChainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10013 /* ChainView.swift */; };
|
||||
AAA00013 /* StatusBadgesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10014 /* StatusBadgesView.swift */; };
|
||||
AAA00014 /* EventsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10015 /* EventsView.swift */; };
|
||||
AAA00015 /* SoundManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10016 /* SoundManager.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
|
|
@ -33,6 +37,10 @@
|
|||
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>"; };
|
||||
AAA10013 /* ChainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChainView.swift; sourceTree = "<group>"; };
|
||||
AAA10014 /* StatusBadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBadgesView.swift; sourceTree = "<group>"; };
|
||||
AAA10015 /* EventsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventsView.swift; sourceTree = "<group>"; };
|
||||
AAA10016 /* SoundManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundManager.swift; sourceTree = "<group>"; };
|
||||
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -108,6 +116,9 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
AAA10008 /* ProgressBarView.swift */,
|
||||
AAA10013 /* ChainView.swift */,
|
||||
AAA10014 /* StatusBadgesView.swift */,
|
||||
AAA10015 /* EventsView.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -118,6 +129,7 @@
|
|||
AAA10009 /* NotificationManager.swift */,
|
||||
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||
AAA10012 /* ShortcutsManager.swift */,
|
||||
AAA10016 /* SoundManager.swift */,
|
||||
);
|
||||
path = Utilities;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -201,6 +213,10 @@
|
|||
AAA00009 /* NotificationManager.swift in Sources */,
|
||||
AAA00010 /* LaunchAtLoginManager.swift in Sources */,
|
||||
AAA00011 /* ShortcutsManager.swift in Sources */,
|
||||
AAA00012 /* ChainView.swift in Sources */,
|
||||
AAA00013 /* StatusBadgesView.swift in Sources */,
|
||||
AAA00014 /* EventsView.swift in Sources */,
|
||||
AAA00015 /* SoundManager.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import SwiftUI
|
|||
class AppState: ObservableObject {
|
||||
// MARK: - Persisted
|
||||
@AppStorage("apiKey") var apiKey: String = ""
|
||||
@AppStorage("refreshInterval") var refreshInterval: Int = 30
|
||||
|
||||
// MARK: - Published State
|
||||
@Published var data: TornResponse?
|
||||
@Published var lastUpdated: Date?
|
||||
@Published var errorMsg: String?
|
||||
@Published var isLoading: Bool = false
|
||||
@Published var notificationRules: [NotificationRule] = []
|
||||
|
||||
// MARK: - Managers
|
||||
let launchAtLogin = LaunchAtLoginManager()
|
||||
|
|
@ -21,23 +23,52 @@ class AppState: ObservableObject {
|
|||
private var previousBars: Bars?
|
||||
private var previousCooldowns: Cooldowns?
|
||||
private var previousTravel: Travel?
|
||||
private var previousChain: Chain?
|
||||
private var previousStatus: Status?
|
||||
|
||||
// MARK: - Timer
|
||||
private var timerCancellable: AnyCancellable?
|
||||
|
||||
init() {
|
||||
loadNotificationRules()
|
||||
startPolling()
|
||||
Task {
|
||||
await NotificationManager.shared.requestPermission()
|
||||
}
|
||||
}
|
||||
|
||||
func loadNotificationRules() {
|
||||
if let data = UserDefaults.standard.data(forKey: "notificationRules"),
|
||||
let rules = try? JSONDecoder().decode([NotificationRule].self, from: data) {
|
||||
notificationRules = rules
|
||||
} else {
|
||||
notificationRules = NotificationRule.defaults
|
||||
saveNotificationRules()
|
||||
}
|
||||
}
|
||||
|
||||
func saveNotificationRules() {
|
||||
if let data = try? JSONEncoder().encode(notificationRules) {
|
||||
UserDefaults.standard.set(data, forKey: "notificationRules")
|
||||
}
|
||||
}
|
||||
|
||||
func updateRule(_ rule: NotificationRule) {
|
||||
if let index = notificationRules.firstIndex(where: { $0.id == rule.id }) {
|
||||
notificationRules[index] = rule
|
||||
saveNotificationRules()
|
||||
}
|
||||
}
|
||||
|
||||
func startPolling() {
|
||||
// Stop existing timer
|
||||
timerCancellable?.cancel()
|
||||
|
||||
// Initial fetch
|
||||
fetchData()
|
||||
|
||||
// Set up 30-second polling
|
||||
timerCancellable = Timer.publish(every: 30, on: .main, in: .common)
|
||||
// Set up polling with configurable interval
|
||||
timerCancellable = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
|
||||
.autoconnect()
|
||||
.sink { [weak self] _ in
|
||||
self?.fetchData()
|
||||
|
|
@ -94,6 +125,8 @@ class AppState: ObservableObject {
|
|||
self.previousBars = decoded.bars
|
||||
self.previousCooldowns = decoded.cooldowns
|
||||
self.previousTravel = decoded.travel
|
||||
self.previousChain = decoded.chain
|
||||
self.previousStatus = decoded.status
|
||||
}
|
||||
case 403, 404:
|
||||
self.errorMsg = "Invalid API Key"
|
||||
|
|
@ -110,57 +143,71 @@ class AppState: ObservableObject {
|
|||
}
|
||||
|
||||
private func checkNotifications(newData: TornResponse) {
|
||||
// Bar notifications
|
||||
// Bar notifications with custom rules
|
||||
if let prev = previousBars, let current = newData.bars {
|
||||
// Energy full notification
|
||||
if prev.energy.current < prev.energy.maximum &&
|
||||
current.energy.current >= current.energy.maximum {
|
||||
NotificationManager.shared.send(
|
||||
title: "Energy Full! ⚡️",
|
||||
body: "Your energy bar is now full (\(current.energy.maximum)/\(current.energy.maximum))"
|
||||
)
|
||||
}
|
||||
|
||||
// Nerve full notification
|
||||
if prev.nerve.current < prev.nerve.maximum &&
|
||||
current.nerve.current >= current.nerve.maximum {
|
||||
NotificationManager.shared.send(
|
||||
title: "Nerve Full! 💪",
|
||||
body: "Your nerve bar is now full (\(current.nerve.maximum)/\(current.nerve.maximum))"
|
||||
)
|
||||
}
|
||||
checkBarNotification(prevBar: prev.energy, currentBar: current.energy, barType: .energy)
|
||||
checkBarNotification(prevBar: prev.nerve, currentBar: current.nerve, barType: .nerve)
|
||||
checkBarNotification(prevBar: prev.happy, currentBar: current.happy, barType: .happy)
|
||||
checkBarNotification(prevBar: prev.life, currentBar: current.life, barType: .life)
|
||||
}
|
||||
|
||||
// Cooldown notifications
|
||||
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
|
||||
if prevCD.drug > 0 && currentCD.drug == 0 {
|
||||
NotificationManager.shared.send(
|
||||
title: "Drug Ready! 💊",
|
||||
body: "Drug cooldown has ended"
|
||||
)
|
||||
NotificationManager.shared.send(title: "Drug Ready! 💊", body: "Drug cooldown has ended")
|
||||
}
|
||||
if prevCD.medical > 0 && currentCD.medical == 0 {
|
||||
NotificationManager.shared.send(
|
||||
title: "Medical Ready! 🏥",
|
||||
body: "Medical cooldown has ended"
|
||||
)
|
||||
NotificationManager.shared.send(title: "Medical Ready! 🏥", body: "Medical cooldown has ended")
|
||||
}
|
||||
if prevCD.booster > 0 && currentCD.booster == 0 {
|
||||
NotificationManager.shared.send(
|
||||
title: "Booster Ready! 🚀",
|
||||
body: "Booster cooldown has ended"
|
||||
)
|
||||
NotificationManager.shared.send(title: "Booster Ready! 🚀", body: "Booster cooldown has ended")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)"
|
||||
)
|
||||
NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination)")
|
||||
}
|
||||
}
|
||||
|
||||
// Chain timeout warning
|
||||
if let chain = newData.chain, chain.isActive {
|
||||
if chain.timeoutRemaining < 60 && chain.timeoutRemaining > 0 {
|
||||
NotificationManager.shared.send(title: "Chain Expiring! ⚠️", body: "Chain timeout in \(chain.timeoutRemaining) seconds!")
|
||||
}
|
||||
}
|
||||
|
||||
// Hospital/Jail release
|
||||
if let prevStatus = previousStatus, let currentStatus = newData.status {
|
||||
if !prevStatus.isOkay && currentStatus.isOkay {
|
||||
NotificationManager.shared.send(title: "Released! 🎉", body: "You are now free")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func checkBarNotification(prevBar: Bar, currentBar: Bar, barType: NotificationRule.BarType) {
|
||||
let prevPct = prevBar.percentage
|
||||
let currentPct = currentBar.percentage
|
||||
|
||||
for rule in notificationRules where rule.enabled && rule.barType == barType {
|
||||
let threshold = Double(rule.threshold)
|
||||
|
||||
// Check if we crossed the threshold upwards
|
||||
if prevPct < threshold && currentPct >= threshold {
|
||||
let title: String
|
||||
switch barType {
|
||||
case .energy: title = "Energy \(rule.threshold)%! ⚡️"
|
||||
case .nerve: title = "Nerve \(rule.threshold)%! 💪"
|
||||
case .happy: title = "Happy \(rule.threshold)%! 😊"
|
||||
case .life: title = "Life \(rule.threshold)%! ❤️"
|
||||
}
|
||||
NotificationManager.shared.send(title: title, body: "\(barType.rawValue) is now at \(currentBar.current)/\(currentBar.maximum)")
|
||||
|
||||
// Play sound
|
||||
if let sound = NotificationSound(rawValue: rule.soundName) {
|
||||
SoundManager.shared.play(sound)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,74 +3,141 @@ import SwiftUI
|
|||
struct SettingsView: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@State private var inputKey: String = ""
|
||||
@State private var showNotificationSettings = false
|
||||
|
||||
// Developer ID for tip feature (bombel)
|
||||
private let developerID = 2362436
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.orange, .yellow],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
// Header
|
||||
Image(systemName: "bolt.circle.fill")
|
||||
.font(.system(size: 48))
|
||||
.foregroundStyle(
|
||||
LinearGradient(
|
||||
colors: [.orange, .yellow],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Text("MacTorn")
|
||||
.font(.title2.bold())
|
||||
Text("MacTorn")
|
||||
.font(.title2.bold())
|
||||
|
||||
Text("Enter your Torn API Key")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
Text("Enter your Torn API Key")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// API Key input
|
||||
SecureField("API Key", text: $inputKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
// API Key input
|
||||
SecureField("API Key", text: $inputKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Save & Connect") {
|
||||
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
appState.refreshNow()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(inputKey.isEmpty)
|
||||
|
||||
Link("Get API Key from Torn",
|
||||
destination: URL(string: "https://www.torn.com/preferences.php#tab=api")!)
|
||||
.font(.caption)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 8)
|
||||
|
||||
// Refresh Interval
|
||||
refreshIntervalSection
|
||||
|
||||
// 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)
|
||||
|
||||
Button("Save & Connect") {
|
||||
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
appState.refreshNow()
|
||||
// Notification Settings
|
||||
Button {
|
||||
showNotificationSettings.toggle()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "bell.badge")
|
||||
Text("Notification Rules")
|
||||
Spacer()
|
||||
Image(systemName: showNotificationSettings ? "chevron.up" : "chevron.down")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if showNotificationSettings {
|
||||
notificationRulesSection
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Tip Me section
|
||||
tipMeSection
|
||||
|
||||
// GitHub link
|
||||
githubSection
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(inputKey.isEmpty)
|
||||
|
||||
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)
|
||||
|
||||
Divider()
|
||||
.padding(.vertical, 4)
|
||||
|
||||
// Tip Me section
|
||||
tipMeSection
|
||||
|
||||
// GitHub link
|
||||
githubSection
|
||||
.padding()
|
||||
}
|
||||
.padding()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.onAppear {
|
||||
inputKey = appState.apiKey
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh Interval
|
||||
private var refreshIntervalSection: some View {
|
||||
HStack {
|
||||
Label("Refresh Interval", systemImage: "clock")
|
||||
.font(.caption)
|
||||
|
||||
Spacer()
|
||||
|
||||
Picker("", selection: Binding(
|
||||
get: { appState.refreshInterval },
|
||||
set: { newValue in
|
||||
appState.refreshInterval = newValue
|
||||
appState.startPolling()
|
||||
}
|
||||
)) {
|
||||
Text("15s").tag(15)
|
||||
Text("30s").tag(30)
|
||||
Text("60s").tag(60)
|
||||
Text("120s").tag(120)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 180)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Notification Rules
|
||||
private var notificationRulesSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(appState.notificationRules) { rule in
|
||||
NotificationRuleRow(rule: rule) { updatedRule in
|
||||
appState.updateRule(updatedRule)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// MARK: - Tip Me Section
|
||||
private var tipMeSection: some View {
|
||||
VStack(spacing: 8) {
|
||||
|
|
@ -125,3 +192,46 @@ struct SettingsView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification Rule Row
|
||||
struct NotificationRuleRow: View {
|
||||
let rule: NotificationRule
|
||||
let onUpdate: (NotificationRule) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Toggle(isOn: Binding(
|
||||
get: { rule.enabled },
|
||||
set: { newValue in
|
||||
var updated = rule
|
||||
updated.enabled = newValue
|
||||
onUpdate(updated)
|
||||
}
|
||||
)) {
|
||||
HStack {
|
||||
Text(rule.barType.rawValue)
|
||||
.font(.caption)
|
||||
Text("@ \(rule.threshold)%")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.toggleStyle(.switch)
|
||||
.controlSize(.small)
|
||||
|
||||
Picker("", selection: Binding(
|
||||
get: { rule.soundName },
|
||||
set: { newValue in
|
||||
var updated = rule
|
||||
updated.soundName = newValue
|
||||
onUpdate(updated)
|
||||
}
|
||||
)) {
|
||||
ForEach(NotificationSound.allCases, id: \.rawValue) { sound in
|
||||
Text(sound.displayName).tag(sound.rawValue)
|
||||
}
|
||||
}
|
||||
.frame(width: 80)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ struct StatusView: View {
|
|||
errorSection(error)
|
||||
}
|
||||
|
||||
// Status badges (Hospital/Jail)
|
||||
if let status = appState.data?.status {
|
||||
StatusBadgesView(status: status)
|
||||
}
|
||||
|
||||
// Chain status
|
||||
if let chain = appState.data?.chain {
|
||||
ChainView(chain: chain)
|
||||
}
|
||||
|
||||
// Travel status
|
||||
if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad {
|
||||
travelSection(travel)
|
||||
|
|
@ -29,6 +39,16 @@ struct StatusView: View {
|
|||
cooldownsSection(cooldowns)
|
||||
}
|
||||
|
||||
// Messages badge
|
||||
if appState.data?.unreadMessagesCount ?? 0 > 0 {
|
||||
messagesBadge
|
||||
}
|
||||
|
||||
// Events
|
||||
if let events = appState.data?.recentEvents, !events.isEmpty {
|
||||
EventsView(events: events)
|
||||
}
|
||||
|
||||
// Quick Links
|
||||
quickLinksSection
|
||||
}
|
||||
|
|
@ -72,6 +92,27 @@ struct StatusView: View {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: - Messages Badge
|
||||
private var messagesBadge: some View {
|
||||
Button {
|
||||
if let url = URL(string: "https://www.torn.com/messages.php") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "envelope.fill")
|
||||
.foregroundColor(.blue)
|
||||
Text("\(appState.data?.unreadMessagesCount ?? 0) unread messages")
|
||||
.font(.caption)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
private func errorSection(_ error: String) -> some View {
|
||||
HStack {
|
||||
|
|
@ -210,12 +251,6 @@ struct StatusView: View {
|
|||
}
|
||||
return String(format: "%d:%02d", minutes, secs)
|
||||
}
|
||||
|
||||
private var timeFormatter: DateFormatter {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cooldown Item
|
||||
|
|
@ -239,9 +274,7 @@ struct CooldownItem: View {
|
|||
}
|
||||
|
||||
private var formattedTime: String {
|
||||
if seconds <= 0 {
|
||||
return "Ready"
|
||||
}
|
||||
if seconds <= 0 { return "Ready" }
|
||||
let hours = seconds / 3600
|
||||
let minutes = (seconds % 3600) / 60
|
||||
let secs = seconds % 60
|
||||
|
|
|
|||
81
README.md
81
README.md
|
|
@ -1,30 +1,69 @@
|
|||
# MacTorn
|
||||
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.
|
||||
|
||||
A native macOS menu bar app for monitoring your **Torn** game status.
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
|
||||
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns
|
||||
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown
|
||||
- 🔗 **Chain Timer** - Active chain counter with timeout warning
|
||||
- 🏥 **Hospital/Jail Status** - Countdown to release
|
||||
- 📨 **Unread Messages** - Quick access to inbox
|
||||
- 🔔 **Events Feed** - Recent activity at a glance
|
||||
- 🔔 **Smart Notifications** - Custom threshold alerts with sound options
|
||||
- ⚡ **Quick Links** - 8 configurable shortcuts to Torn pages
|
||||
- 🚀 **Launch at Login** - Start automatically with macOS
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the latest release from [Releases](https://github.com/pawelorzech/MacTorn/releases)
|
||||
2. Unzip and drag `MacTorn.app` to your Applications folder
|
||||
3. Open MacTorn from Applications
|
||||
4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api)
|
||||
|
||||
> **Note**: On first launch, macOS may show a security warning. Right-click the app and select "Open" to bypass.
|
||||
|
||||
## 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.
|
||||
- macOS 13.0 (Ventura) or later
|
||||
- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages
|
||||
|
||||
Get an API key from `https://www.torn.com/preferences.php#tab=api`.
|
||||
## Configuration
|
||||
|
||||
## 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).
|
||||
### Refresh Interval
|
||||
Choose polling frequency: 15s, 30s, 60s, or 120s
|
||||
|
||||
## Notes
|
||||
- The app polls the Torn API every 30 seconds.
|
||||
- Your API key and Quick Links are stored locally in `UserDefaults`.
|
||||
### Notification Rules
|
||||
Customize when to receive alerts:
|
||||
- Energy/Nerve/Happy/Life at specific thresholds
|
||||
- Sound selection per notification type
|
||||
|
||||
### Quick Links
|
||||
8 preset shortcuts to common Torn pages (fully editable)
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pawelorzech/MacTorn.git
|
||||
cd MacTorn/MacTorn
|
||||
open MacTorn.xcodeproj
|
||||
```
|
||||
|
||||
Press `Cmd + R` to build and run.
|
||||
|
||||
## Support the Developer
|
||||
|
||||
If you find MacTorn useful, send some Xanax or cash to **bombel** [[2362436](https://www.torn.com/profiles.php?XID=2362436)]!
|
||||
|
||||
## License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE) for details.
|
||||
|
||||
---
|
||||
|
||||
Made with ⚡ for the Torn community
|
||||
|
|
|
|||
Loading…
Reference in a new issue