mirror of
https://github.com/pawelorzech/MacTorn.git
synced 2026-01-30 04:04: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 */; };
|
AAA00009 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10009 /* NotificationManager.swift */; };
|
||||||
AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; };
|
AAA00010 /* LaunchAtLoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10011 /* LaunchAtLoginManager.swift */; };
|
||||||
AAA00011 /* ShortcutsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA10012 /* ShortcutsManager.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 */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
|
@ -33,6 +37,10 @@
|
||||||
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>"; };
|
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>"; };
|
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; };
|
AAA10000 /* MacTorn.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MacTorn.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
|
@ -108,6 +116,9 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
AAA10008 /* ProgressBarView.swift */,
|
AAA10008 /* ProgressBarView.swift */,
|
||||||
|
AAA10013 /* ChainView.swift */,
|
||||||
|
AAA10014 /* StatusBadgesView.swift */,
|
||||||
|
AAA10015 /* EventsView.swift */,
|
||||||
);
|
);
|
||||||
path = Components;
|
path = Components;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -118,6 +129,7 @@
|
||||||
AAA10009 /* NotificationManager.swift */,
|
AAA10009 /* NotificationManager.swift */,
|
||||||
AAA10011 /* LaunchAtLoginManager.swift */,
|
AAA10011 /* LaunchAtLoginManager.swift */,
|
||||||
AAA10012 /* ShortcutsManager.swift */,
|
AAA10012 /* ShortcutsManager.swift */,
|
||||||
|
AAA10016 /* SoundManager.swift */,
|
||||||
);
|
);
|
||||||
path = Utilities;
|
path = Utilities;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -201,6 +213,10 @@
|
||||||
AAA00009 /* NotificationManager.swift in Sources */,
|
AAA00009 /* NotificationManager.swift in Sources */,
|
||||||
AAA00010 /* LaunchAtLoginManager.swift in Sources */,
|
AAA00010 /* LaunchAtLoginManager.swift in Sources */,
|
||||||
AAA00011 /* ShortcutsManager.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;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,12 +6,14 @@ import SwiftUI
|
||||||
class AppState: ObservableObject {
|
class AppState: ObservableObject {
|
||||||
// MARK: - Persisted
|
// MARK: - Persisted
|
||||||
@AppStorage("apiKey") var apiKey: String = ""
|
@AppStorage("apiKey") var apiKey: String = ""
|
||||||
|
@AppStorage("refreshInterval") var refreshInterval: Int = 30
|
||||||
|
|
||||||
// MARK: - Published State
|
// MARK: - Published State
|
||||||
@Published var data: TornResponse?
|
@Published var data: TornResponse?
|
||||||
@Published var lastUpdated: Date?
|
@Published var lastUpdated: Date?
|
||||||
@Published var errorMsg: String?
|
@Published var errorMsg: String?
|
||||||
@Published var isLoading: Bool = false
|
@Published var isLoading: Bool = false
|
||||||
|
@Published var notificationRules: [NotificationRule] = []
|
||||||
|
|
||||||
// MARK: - Managers
|
// MARK: - Managers
|
||||||
let launchAtLogin = LaunchAtLoginManager()
|
let launchAtLogin = LaunchAtLoginManager()
|
||||||
|
|
@ -21,23 +23,52 @@ class AppState: ObservableObject {
|
||||||
private var previousBars: Bars?
|
private var previousBars: Bars?
|
||||||
private var previousCooldowns: Cooldowns?
|
private var previousCooldowns: Cooldowns?
|
||||||
private var previousTravel: Travel?
|
private var previousTravel: Travel?
|
||||||
|
private var previousChain: Chain?
|
||||||
|
private var previousStatus: Status?
|
||||||
|
|
||||||
// MARK: - Timer
|
// MARK: - Timer
|
||||||
private var timerCancellable: AnyCancellable?
|
private var timerCancellable: AnyCancellable?
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
loadNotificationRules()
|
||||||
startPolling()
|
startPolling()
|
||||||
Task {
|
Task {
|
||||||
await NotificationManager.shared.requestPermission()
|
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() {
|
func startPolling() {
|
||||||
|
// Stop existing timer
|
||||||
|
timerCancellable?.cancel()
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
||||||
// Set up 30-second polling
|
// Set up polling with configurable interval
|
||||||
timerCancellable = Timer.publish(every: 30, on: .main, in: .common)
|
timerCancellable = Timer.publish(every: Double(refreshInterval), on: .main, in: .common)
|
||||||
.autoconnect()
|
.autoconnect()
|
||||||
.sink { [weak self] _ in
|
.sink { [weak self] _ in
|
||||||
self?.fetchData()
|
self?.fetchData()
|
||||||
|
|
@ -94,6 +125,8 @@ class AppState: ObservableObject {
|
||||||
self.previousBars = decoded.bars
|
self.previousBars = decoded.bars
|
||||||
self.previousCooldowns = decoded.cooldowns
|
self.previousCooldowns = decoded.cooldowns
|
||||||
self.previousTravel = decoded.travel
|
self.previousTravel = decoded.travel
|
||||||
|
self.previousChain = decoded.chain
|
||||||
|
self.previousStatus = decoded.status
|
||||||
}
|
}
|
||||||
case 403, 404:
|
case 403, 404:
|
||||||
self.errorMsg = "Invalid API Key"
|
self.errorMsg = "Invalid API Key"
|
||||||
|
|
@ -110,57 +143,71 @@ class AppState: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func checkNotifications(newData: TornResponse) {
|
private func checkNotifications(newData: TornResponse) {
|
||||||
// Bar notifications
|
// Bar notifications with custom rules
|
||||||
if let prev = previousBars, let current = newData.bars {
|
if let prev = previousBars, let current = newData.bars {
|
||||||
// Energy full notification
|
checkBarNotification(prevBar: prev.energy, currentBar: current.energy, barType: .energy)
|
||||||
if prev.energy.current < prev.energy.maximum &&
|
checkBarNotification(prevBar: prev.nerve, currentBar: current.nerve, barType: .nerve)
|
||||||
current.energy.current >= current.energy.maximum {
|
checkBarNotification(prevBar: prev.happy, currentBar: current.happy, barType: .happy)
|
||||||
NotificationManager.shared.send(
|
checkBarNotification(prevBar: prev.life, currentBar: current.life, barType: .life)
|
||||||
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))"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cooldown notifications
|
// Cooldown notifications
|
||||||
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
|
if let prevCD = previousCooldowns, let currentCD = newData.cooldowns {
|
||||||
if prevCD.drug > 0 && currentCD.drug == 0 {
|
if prevCD.drug > 0 && currentCD.drug == 0 {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(title: "Drug Ready! 💊", body: "Drug cooldown has ended")
|
||||||
title: "Drug Ready! 💊",
|
|
||||||
body: "Drug cooldown has ended"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if prevCD.medical > 0 && currentCD.medical == 0 {
|
if prevCD.medical > 0 && currentCD.medical == 0 {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(title: "Medical Ready! 🏥", body: "Medical cooldown has ended")
|
||||||
title: "Medical Ready! 🏥",
|
|
||||||
body: "Medical cooldown has ended"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if prevCD.booster > 0 && currentCD.booster == 0 {
|
if prevCD.booster > 0 && currentCD.booster == 0 {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(title: "Booster Ready! 🚀", body: "Booster cooldown has ended")
|
||||||
title: "Booster Ready! 🚀",
|
|
||||||
body: "Booster cooldown has ended"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Travel notifications
|
// Travel notifications
|
||||||
if let prevTravel = previousTravel, let currentTravel = newData.travel {
|
if let prevTravel = previousTravel, let currentTravel = newData.travel {
|
||||||
// Landed notification
|
|
||||||
if prevTravel.isTraveling && !currentTravel.isTraveling {
|
if prevTravel.isTraveling && !currentTravel.isTraveling {
|
||||||
NotificationManager.shared.send(
|
NotificationManager.shared.send(title: "Landed! ✈️", body: "You have arrived in \(currentTravel.destination)")
|
||||||
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,11 +3,13 @@ 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 showNotificationSettings = false
|
||||||
|
|
||||||
// Developer ID for tip feature (bombel)
|
// Developer ID for tip feature (bombel)
|
||||||
private let developerID = 2362436
|
private let developerID = 2362436
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
// Header
|
// Header
|
||||||
Image(systemName: "bolt.circle.fill")
|
Image(systemName: "bolt.circle.fill")
|
||||||
|
|
@ -46,6 +48,9 @@ struct SettingsView: View {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// Refresh Interval
|
||||||
|
refreshIntervalSection
|
||||||
|
|
||||||
// Launch at Login
|
// Launch at Login
|
||||||
Toggle(isOn: Binding(
|
Toggle(isOn: Binding(
|
||||||
get: { appState.launchAtLogin.isEnabled },
|
get: { appState.launchAtLogin.isEnabled },
|
||||||
|
|
@ -56,6 +61,25 @@ struct SettingsView: View {
|
||||||
.toggleStyle(.switch)
|
.toggleStyle(.switch)
|
||||||
.padding(.horizontal)
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
// 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()
|
Divider()
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
|
@ -66,11 +90,54 @@ struct SettingsView: View {
|
||||||
githubSection
|
githubSection
|
||||||
}
|
}
|
||||||
.padding()
|
.padding()
|
||||||
|
}
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
inputKey = appState.apiKey
|
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
|
// MARK: - Tip Me Section
|
||||||
private var tipMeSection: some View {
|
private var tipMeSection: some View {
|
||||||
VStack(spacing: 8) {
|
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)
|
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
|
// Travel status
|
||||||
if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad {
|
if let travel = appState.data?.travel, travel.isTraveling || travel.isAbroad {
|
||||||
travelSection(travel)
|
travelSection(travel)
|
||||||
|
|
@ -29,6 +39,16 @@ struct StatusView: View {
|
||||||
cooldownsSection(cooldowns)
|
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
|
// Quick Links
|
||||||
quickLinksSection
|
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
|
// MARK: - Error
|
||||||
private func errorSection(_ error: String) -> some View {
|
private func errorSection(_ error: String) -> some View {
|
||||||
HStack {
|
HStack {
|
||||||
|
|
@ -210,12 +251,6 @@ struct StatusView: View {
|
||||||
}
|
}
|
||||||
return String(format: "%d:%02d", minutes, secs)
|
return String(format: "%d:%02d", minutes, secs)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var timeFormatter: DateFormatter {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.timeStyle = .short
|
|
||||||
return formatter
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Cooldown Item
|
// MARK: - Cooldown Item
|
||||||
|
|
@ -239,9 +274,7 @@ struct CooldownItem: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var formattedTime: String {
|
private var formattedTime: String {
|
||||||
if seconds <= 0 {
|
if seconds <= 0 { return "Ready" }
|
||||||
return "Ready"
|
|
||||||
}
|
|
||||||
let hours = seconds / 3600
|
let hours = seconds / 3600
|
||||||
let minutes = (seconds % 3600) / 60
|
let minutes = (seconds % 3600) / 60
|
||||||
let secs = seconds % 60
|
let secs = seconds % 60
|
||||||
|
|
|
||||||
81
README.md
81
README.md
|
|
@ -1,30 +1,69 @@
|
||||||
# MacTorn
|
# 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
|
## Features
|
||||||
- Menu bar status window with energy, nerve, happy, and life bars.
|
|
||||||
- Cooldown timers for drug, medical, and booster.
|
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
|
||||||
- Travel status with remaining time and destination.
|
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns
|
||||||
- Local notifications when bars fill, cooldowns end, or you land.
|
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown
|
||||||
- Quick links grid for Torn pages with editable labels and URLs.
|
- 🔗 **Chain Timer** - Active chain counter with timeout warning
|
||||||
- Launch at login toggle.
|
- 🏥 **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
|
## Requirements
|
||||||
- macOS 13.0 or later.
|
|
||||||
- A Torn API key.
|
|
||||||
|
|
||||||
## Setup
|
- macOS 13.0 (Ventura) or later
|
||||||
1. Open the app and paste your Torn API key.
|
- Torn API Key with access to: basic, bars, cooldowns, travel, profile, events, messages
|
||||||
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`.
|
## Configuration
|
||||||
|
|
||||||
## Build and Run
|
### Refresh Interval
|
||||||
1. Open `MacTorn/MacTorn.xcodeproj` in Xcode.
|
Choose polling frequency: 15s, 30s, 60s, or 120s
|
||||||
2. Select the MacTorn scheme.
|
|
||||||
3. Run the app (it appears in the menu bar).
|
|
||||||
|
|
||||||
## Notes
|
### Notification Rules
|
||||||
- The app polls the Torn API every 30 seconds.
|
Customize when to receive alerts:
|
||||||
- Your API key and Quick Links are stored locally in `UserDefaults`.
|
- 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