Make Torn models more robust to missing API data

Updated TornModels.swift to make many properties optional, improving resilience to missing or incomplete API responses. Adjusted ChainView and StatusView to handle optional values safely. Refactored SettingsView for a cleaner layout and removed notification rules UI. Updated README to reflect notification changes and clarify features.
This commit is contained in:
Paweł Orzech 2026-01-17 19:53:28 +00:00
parent 53a234afcd
commit 4813da8c7f
No known key found for this signature in database
6 changed files with 138 additions and 229 deletions

Binary file not shown.

View file

@ -85,10 +85,10 @@ struct Cooldowns: Codable, Equatable {
// MARK: - Travel // MARK: - Travel
struct Travel: Codable, Equatable { struct Travel: Codable, Equatable {
let destination: String let destination: String?
let timestamp: Int let timestamp: Int?
let departed: Int let departed: Int?
let timeLeft: Int let timeLeft: Int?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case destination case destination
@ -98,25 +98,27 @@ struct Travel: Codable, Equatable {
} }
var isAbroad: Bool { var isAbroad: Bool {
destination != "Torn" && timeLeft == 0 guard let dest = destination, let time = timeLeft else { return false }
return dest != "Torn" && time == 0
} }
var isTraveling: Bool { var isTraveling: Bool {
timeLeft > 0 guard let time = timeLeft else { return false }
return time > 0
} }
var arrivalDate: Date? { var arrivalDate: Date? {
guard isTraveling else { return nil } guard isTraveling, let ts = timestamp else { return nil }
return Date(timeIntervalSince1970: TimeInterval(timestamp)) return Date(timeIntervalSince1970: TimeInterval(ts))
} }
} }
// MARK: - Status (Hospital/Jail) // MARK: - Status (Hospital/Jail)
struct Status: Codable, Equatable { struct Status: Codable, Equatable {
let description: String let description: String?
let details: String? let details: String?
let state: String let state: String?
let until: Int let until: Int?
var isInHospital: Bool { var isInHospital: Bool {
state == "Hospital" state == "Hospital"
@ -127,31 +129,35 @@ struct Status: Codable, Equatable {
} }
var isOkay: Bool { var isOkay: Bool {
state == "Okay" state == "Okay" || state == nil
} }
var timeRemaining: Int { var timeRemaining: Int {
max(0, until - Int(Date().timeIntervalSince1970)) guard let until = until else { return 0 }
return max(0, until - Int(Date().timeIntervalSince1970))
} }
} }
// MARK: - Chain // MARK: - Chain
struct Chain: Codable, Equatable { struct Chain: Codable, Equatable {
let current: Int let current: Int?
let maximum: Int let maximum: Int?
let timeout: Int let timeout: Int?
let cooldown: Int let cooldown: Int?
var isActive: Bool { var isActive: Bool {
current > 0 && timeout > 0 guard let current = current, let timeout = timeout else { return false }
return current > 0 && timeout > 0
} }
var isOnCooldown: Bool { var isOnCooldown: Bool {
cooldown > 0 guard let cooldown = cooldown else { return false }
return cooldown > 0
} }
var timeoutRemaining: Int { var timeoutRemaining: Int {
max(0, timeout - Int(Date().timeIntervalSince1970)) guard let timeout = timeout else { return 0 }
return max(0, timeout - Int(Date().timeIntervalSince1970))
} }
} }
@ -159,7 +165,7 @@ struct Chain: Codable, Equatable {
struct TornEvent: Codable, Identifiable { struct TornEvent: Codable, Identifiable {
let timestamp: Int let timestamp: Int
let event: String let event: String
let seen: Int let seen: Int?
var id: Int { timestamp } var id: Int { timestamp }
@ -175,11 +181,11 @@ struct TornEvent: Codable, Identifiable {
// MARK: - Messages // MARK: - Messages
struct TornMessage: Codable { struct TornMessage: Codable {
let name: String let name: String?
let type: String let type: String?
let title: String let title: String?
let timestamp: Int let timestamp: Int?
let read: Int let read: Int?
} }
// MARK: - Error // MARK: - Error

View file

@ -9,7 +9,7 @@ struct ChainView: View {
HStack { HStack {
Image(systemName: "link") Image(systemName: "link")
.foregroundColor(timeoutColor) .foregroundColor(timeoutColor)
Text("Chain: \(chain.current)/\(chain.maximum)") Text("Chain: \(chain.current ?? 0)/\(chain.maximum ?? 0)")
.font(.caption.bold()) .font(.caption.bold())
Spacer() Spacer()

View file

@ -3,14 +3,12 @@ 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: 20) {
VStack(spacing: 16) {
// Header // Header
Image(systemName: "bolt.circle.fill") Image(systemName: "bolt.circle.fill")
.font(.system(size: 48)) .font(.system(size: 48))
@ -25,14 +23,10 @@ struct SettingsView: View {
Text("MacTorn") Text("MacTorn")
.font(.title2.bold()) .font(.title2.bold())
Text("Enter your Torn API Key") // API Key section
.font(.caption) VStack(spacing: 8) {
.foregroundColor(.secondary) SecureField("Torn API Key", text: $inputKey)
// API Key input
SecureField("API Key", text: $inputKey)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.padding(.horizontal)
Button("Save & Connect") { Button("Save & Connect") {
appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines) appState.apiKey = inputKey.trimmingCharacters(in: .whitespacesAndNewlines)
@ -44,68 +38,20 @@ 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)
}
.padding(.horizontal)
Divider() Divider()
.padding(.vertical, 8)
// Settings
VStack(spacing: 12) {
// Refresh Interval // 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)
// Notification Settings
Button {
showNotificationSettings.toggle()
} label: {
HStack { HStack {
Image(systemName: "bell.badge") Image(systemName: "clock")
Text("Notification Rules") .foregroundColor(.secondary)
Spacer() .frame(width: 20)
Image(systemName: showNotificationSettings ? "chevron.up" : "chevron.down")
}
.font(.caption)
.padding(.horizontal)
}
.buttonStyle(.plain)
if showNotificationSettings { Picker("Refresh", selection: Binding(
notificationRulesSection
}
Divider()
.padding(.vertical, 4)
// Tip Me section
tipMeSection
// GitHub link
githubSection
}
.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 }, get: { appState.refreshInterval },
set: { newValue in set: { newValue in
appState.refreshInterval = newValue appState.refreshInterval = newValue
@ -115,33 +61,30 @@ struct SettingsView: View {
Text("15s").tag(15) Text("15s").tag(15)
Text("30s").tag(30) Text("30s").tag(30)
Text("60s").tag(60) Text("60s").tag(60)
Text("120s").tag(120) Text("2m").tag(120)
} }
.pickerStyle(.segmented) .pickerStyle(.segmented)
.frame(width: 180)
}
.padding(.horizontal)
} }
// MARK: - Notification Rules // Launch at Login
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) {
HStack { HStack {
Image(systemName: "power")
.foregroundColor(.secondary)
.frame(width: 20)
Toggle("Launch at Login", isOn: Binding(
get: { appState.launchAtLogin.isEnabled },
set: { _ in appState.launchAtLogin.toggle() }
))
.toggleStyle(.switch)
}
}
.padding(.horizontal)
Divider()
// Support section
VStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: "gift.fill") Image(systemName: "gift.fill")
.foregroundColor(.purple) .foregroundColor(.purple)
Text("Support the Developer") Text("Support the Developer")
@ -151,40 +94,44 @@ struct SettingsView: View {
Text("Send me some Xanax or cash :)") Text("Send me some Xanax or cash :)")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button { Button {
openTornProfile() openTornProfile()
} label: { } label: {
HStack { HStack(spacing: 4) {
Image(systemName: "paperplane.fill") Image(systemName: "paperplane.fill")
Text("Send Xanax to bombel") Text("Send to bombel")
} }
.font(.caption) .font(.caption)
.padding(.vertical, 8) .padding(.vertical, 6)
.padding(.horizontal, 16) .padding(.horizontal, 12)
.background(Color.purple.opacity(0.15)) .background(Color.purple.opacity(0.15))
.cornerRadius(8) .cornerRadius(6)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
.padding() .padding(.vertical, 8)
.padding(.horizontal)
.background(Color.purple.opacity(0.05)) .background(Color.purple.opacity(0.05))
.cornerRadius(8) .cornerRadius(8)
}
// MARK: - GitHub Section // GitHub
private var githubSection: some View { HStack(spacing: 4) {
HStack {
Image(systemName: "chevron.left.forwardslash.chevron.right") Image(systemName: "chevron.left.forwardslash.chevron.right")
.font(.caption2)
.foregroundColor(.gray) .foregroundColor(.gray)
Link("View Source on GitHub", Link("View on GitHub",
destination: URL(string: "https://github.com/pawelorzech/MacTorn")!) destination: URL(string: "https://github.com/pawelorzech/MacTorn")!)
.font(.caption) .font(.caption)
} }
} }
.padding()
.frame(width: 320)
.onAppear {
inputKey = appState.apiKey
}
}
// MARK: - Helpers
private func openTornProfile() { private func openTornProfile() {
let url = "https://www.torn.com/profiles.php?XID=\(developerID)" let url = "https://www.torn.com/profiles.php?XID=\(developerID)"
if let url = URL(string: url) { if let url = URL(string: url) {
@ -192,46 +139,3 @@ 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)
}
}
}

View file

@ -131,7 +131,7 @@ struct StatusView: View {
HStack { HStack {
Image(systemName: "airplane") Image(systemName: "airplane")
.foregroundColor(.blue) .foregroundColor(.blue)
Text(travel.isTraveling ? "Traveling to \(travel.destination)" : "In \(travel.destination)") Text(travel.isTraveling ? "Traveling to \(travel.destination ?? "Unknown")" : "In \(travel.destination ?? "Unknown")")
.font(.caption.bold()) .font(.caption.bold())
} }
@ -140,7 +140,7 @@ struct StatusView: View {
Text("Arriving in:") Text("Arriving in:")
.font(.caption2) .font(.caption2)
.foregroundColor(.secondary) .foregroundColor(.secondary)
Text(formatTime(travel.timeLeft)) Text(formatTime(travel.timeLeft ?? 0))
.font(.caption.monospacedDigit()) .font(.caption.monospacedDigit())
.foregroundColor(.blue) .foregroundColor(.blue)
} }

View file

@ -9,14 +9,15 @@ A native macOS menu bar app for monitoring your **Torn** game status.
## Features ## Features
- 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress - 📊 **Live Status Bars** - Energy, Nerve, Happy, Life with color-coded progress
- ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns - ⏱️ **Cooldown Timers** - Drug, Medical, Booster countdowns with ready state
- ✈️ **Travel Monitoring** - Destination tracking with arrival countdown - ✈️ **Travel Monitoring** - Destination tracking with arrival countdown and abroad state
- 🔗 **Chain Timer** - Active chain counter with timeout warning - 🔗 **Chain Timer** - Active chain counter with timeout warning + cooldown state
- 🏥 **Hospital/Jail Status** - Countdown to release - 🏥 **Hospital/Jail Status** - Countdown to release
- 📨 **Unread Messages** - Quick access to inbox - 📨 **Unread Messages** - Inbox badge with one-click open
- 🔔 **Events Feed** - Recent activity at a glance - 🔔 **Events Feed** - Recent activity at a glance
- 🔔 **Smart Notifications** - Custom threshold alerts with sound options - 🔔 **Notifications** - Bars thresholds, cooldown ready, landing, chain expiring, and release
- ⚡ **Quick Links** - 8 configurable shortcuts to Torn pages - ⚡ **Quick Links** - Grid of customizable Torn shortcuts (8 defaults)
- 🕒 **Refresh Control** - 15s/30s/60s/2m polling + manual refresh + last updated
- 🚀 **Launch at Login** - Start automatically with macOS - 🚀 **Launch at Login** - Start automatically with macOS
## Installation ## Installation
@ -26,7 +27,7 @@ A native macOS menu bar app for monitoring your **Torn** game status.
3. Open MacTorn from Applications 3. Open MacTorn from Applications
4. Enter your [Torn API Key](https://www.torn.com/preferences.php#tab=api) 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. > **Note**: If you download an unsigned build, macOS Gatekeeper will block it. Right-click the app and select "Open", or go to System Settings → Privacy & Security → Open Anyway.
## Requirements ## Requirements
@ -38,10 +39,8 @@ A native macOS menu bar app for monitoring your **Torn** game status.
### Refresh Interval ### Refresh Interval
Choose polling frequency: 15s, 30s, 60s, or 120s Choose polling frequency: 15s, 30s, 60s, or 120s
### Notification Rules ### Notifications
Customize when to receive alerts: MacTorn sends notifications for bar thresholds, cooldown ready, landing, chain expiring, and release. Notification defaults are stored locally.
- Energy/Nerve/Happy/Life at specific thresholds
- Sound selection per notification type
### Quick Links ### Quick Links
8 preset shortcuts to common Torn pages (fully editable) 8 preset shortcuts to common Torn pages (fully editable)