MacTorn/MacTorn/MacTorn/Models/TornModels.swift
Paweł Orzech 22f6e5d8e4
Fix safety issues, deprecated APIs, and code quality improvements
- Replace force unwrap developer.tornID! with safe optional binding
- Generate travel notification IDs dynamically from TravelNotificationSetting.defaults
  instead of hardcoded strings, preventing cancellation gaps
- Extract duplicated developer ID (2362436) into TornConstants enum
- Change DateFormatter from computed property to static let to avoid
  expensive re-creation on every render
- Remove redundant objectWillChange.send() already handled by @Published
- Migrate deprecated onChange(of:) { _ in } to new parameterless closure form
2026-02-27 23:34:25 +01:00

795 lines
26 KiB
Swift

import Foundation
import SwiftUI
// MARK: - Constants
enum TornConstants {
static let developerID = 2362436
}
// MARK: - Root Response
struct TornResponse: Codable {
let name: String?
let playerId: Int?
let energy: Bar?
let nerve: Bar?
let life: Bar?
let happy: Bar?
let cooldowns: Cooldowns?
let travel: Travel?
let status: Status?
let chain: Chain?
let events: [String: TornEvent]?
let messages: [String: TornMessage]?
let error: TornError?
enum CodingKeys: String, CodingKey {
case name
case playerId = "player_id"
case energy, nerve, life, happy
case cooldowns, travel, status, chain
case events, messages, error
}
// Convenience computed property
var bars: Bars? {
guard let energy = energy,
let nerve = nerve,
let life = life,
let happy = happy else { return nil }
return Bars(energy: energy, nerve: nerve, life: life, happy: happy)
}
// Unread messages count
var unreadMessagesCount: Int {
messages?.values.filter { $0.read == 0 }.count ?? 0
}
// Recent events sorted
var recentEvents: [TornEvent] {
guard let events = events else { return [] }
return events.values.sorted { $0.timestamp > $1.timestamp }
}
}
// MARK: - Bars
struct Bar: Codable, Equatable {
let current: Int
let maximum: Int
let increment: Double?
let interval: Int?
let ticktime: Int?
let fulltime: Int?
init(current: Int, maximum: Int, increment: Double? = nil, interval: Int? = nil, ticktime: Int? = nil, fulltime: Int? = nil) {
self.current = current
self.maximum = maximum
self.increment = increment
self.interval = interval
self.ticktime = ticktime
self.fulltime = fulltime
}
var percentage: Double {
guard maximum > 0 else { return 0 }
return Double(current) / Double(maximum) * 100
}
}
struct Bars: Equatable {
let energy: Bar
let nerve: Bar
let life: Bar
let happy: Bar
}
// MARK: - Cooldowns
struct Cooldowns: Codable, Equatable {
let drug: Int
let medical: Int
let booster: Int
}
// MARK: - Travel
struct Travel: Codable, Equatable {
let destination: String?
let timestamp: Int?
let departed: Int?
let timeLeft: Int?
enum CodingKeys: String, CodingKey {
case destination
case timestamp
case departed
case timeLeft = "time_left"
}
var isAbroad: Bool {
guard let dest = destination, let time = timeLeft else { return false }
return dest != "Torn" && time == 0
}
var isTraveling: Bool {
guard let time = timeLeft else { return false }
return time > 0
}
var arrivalDate: Date? {
guard isTraveling, let ts = timestamp else { return nil }
return Date(timeIntervalSince1970: TimeInterval(ts))
}
/// Calculate remaining seconds based on fetch time (for live countdown)
func remainingSeconds(from fetchTime: Date) -> Int {
// Primary: Use timestamp directly if available (more accurate)
if let timestamp = timestamp, timestamp > 0 {
let now = Int(Date().timeIntervalSince1970)
return max(0, timestamp - now)
}
// Fallback: Use timeLeft with fetchTime offset (backward compatibility)
guard let timeLeft = timeLeft, timeLeft > 0 else { return 0 }
let elapsed = Int(Date().timeIntervalSince(fetchTime))
return max(0, timeLeft - elapsed)
}
/// Calculate flight progress (0.0 to 1.0) based on fetch time
func flightProgress(from fetchTime: Date) -> Double {
guard let departed = departed, let timestamp = timestamp else { return 0 }
let totalDuration = timestamp - departed
guard totalDuration > 0 else { return 0 }
let remaining = remainingSeconds(from: fetchTime)
let elapsed = totalDuration - remaining
return min(1.0, max(0.0, Double(elapsed) / Double(totalDuration)))
}
}
// MARK: - Travel Destinations
enum TornDestination: String, CaseIterable, Identifiable {
case mexico = "Mexico"
case caymanIslands = "Cayman Islands"
case canada = "Canada"
case hawaii = "Hawaii"
case unitedKingdom = "United Kingdom"
case argentina = "Argentina"
case switzerland = "Switzerland"
case japan = "Japan"
case china = "China"
case uae = "UAE"
case southAfrica = "South Africa"
var id: String { rawValue }
var flag: String {
switch self {
case .mexico: return "🇲🇽"
case .caymanIslands: return "🇰🇾"
case .canada: return "🇨🇦"
case .hawaii: return "🇺🇸"
case .unitedKingdom: return "🇬🇧"
case .argentina: return "🇦🇷"
case .switzerland: return "🇨🇭"
case .japan: return "🇯🇵"
case .china: return "🇨🇳"
case .uae: return "🇦🇪"
case .southAfrica: return "🇿🇦"
}
}
/// Approximate flight time in minutes
var flightTimeMinutes: Int {
switch self {
case .mexico: return 26
case .caymanIslands: return 35
case .canada: return 41
case .hawaii: return 134
case .unitedKingdom: return 159
case .argentina: return 167
case .switzerland: return 175
case .japan: return 225
case .china: return 242
case .uae: return 271
case .southAfrica: return 297
}
}
var flightTimeFormatted: String {
let hours = flightTimeMinutes / 60
let minutes = flightTimeMinutes % 60
if hours > 0 {
return "\(hours)h \(minutes)m"
}
return "\(minutes)m"
}
var travelAgencyURL: URL {
URL(string: "https://www.torn.com/travelagency.php")!
}
}
// MARK: - Travel Notification Settings
struct TravelNotificationSetting: Codable, Identifiable, Equatable {
let id: String
let secondsBefore: Int
var enabled: Bool
var displayName: String {
if secondsBefore >= 60 {
return "\(secondsBefore / 60) min before"
}
return "\(secondsBefore) sec before"
}
static let defaults: [TravelNotificationSetting] = [
TravelNotificationSetting(id: "travel_2min", secondsBefore: 120, enabled: false),
TravelNotificationSetting(id: "travel_1min", secondsBefore: 60, enabled: true),
TravelNotificationSetting(id: "travel_30sec", secondsBefore: 30, enabled: false),
TravelNotificationSetting(id: "travel_10sec", secondsBefore: 10, enabled: false)
]
}
// MARK: - Status (Hospital/Jail)
struct Status: Codable, Equatable {
let description: String?
let details: String?
let state: String?
let until: Int?
var isInHospital: Bool {
state == "Hospital"
}
var isInJail: Bool {
state == "Jail"
}
var isOkay: Bool {
state == "Okay" || state == nil
}
var timeRemaining: Int {
guard let until = until else { return 0 }
return max(0, until - Int(Date().timeIntervalSince1970))
}
}
// MARK: - Chain
struct Chain: Codable, Equatable {
let current: Int?
let maximum: Int?
let timeout: Int?
let cooldown: Int?
var isActive: Bool {
guard let current = current, let timeout = timeout else { return false }
return current > 0 && timeout > 0
}
var isOnCooldown: Bool {
guard let cooldown = cooldown else { return false }
return cooldown > 0
}
var timeoutRemaining: Int {
guard let timeout = timeout else { return 0 }
return max(0, timeout - Int(Date().timeIntervalSince1970))
}
}
// MARK: - Events
struct TornEvent: Codable, Identifiable {
let timestamp: Int
let event: String
let seen: Int?
var id: Int { timestamp }
var date: Date {
Date(timeIntervalSince1970: TimeInterval(timestamp))
}
// Strip HTML tags from event text
var cleanEvent: String {
event.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
}
}
// MARK: - Messages
struct TornMessage: Codable {
let name: String?
let type: String?
let title: String?
let timestamp: Int?
let read: Int?
}
// MARK: - Money
struct MoneyData: Codable {
let cash: Int
let vault: Int
let points: Int
let tokens: Int
let cayman: Int
enum CodingKeys: String, CodingKey {
case cash = "money_onhand"
case vault = "vault_amount"
case points
case tokens = "company_funds"
case cayman = "cayman_bank"
}
init(cash: Int = 0, vault: Int = 0, points: Int = 0, tokens: Int = 0, cayman: Int = 0) {
self.cash = cash
self.vault = vault
self.points = points
self.tokens = tokens
self.cayman = cayman
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
cash = (try? container.decode(Int.self, forKey: .cash)) ?? 0
vault = (try? container.decode(Int.self, forKey: .vault)) ?? 0
points = (try? container.decode(Int.self, forKey: .points)) ?? 0
tokens = (try? container.decode(Int.self, forKey: .tokens)) ?? 0
cayman = (try? container.decode(Int.self, forKey: .cayman)) ?? 0
}
}
// MARK: - Battle Stats
struct BattleStats: Codable {
let strength: Int
let defense: Int
let speed: Int
let dexterity: Int
let total: Int
init(strength: Int = 0, defense: Int = 0, speed: Int = 0, dexterity: Int = 0) {
self.strength = strength
self.defense = defense
self.speed = speed
self.dexterity = dexterity
self.total = strength + defense + speed + dexterity
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
strength = (try? container.decode(Int.self, forKey: .strength)) ?? 0
defense = (try? container.decode(Int.self, forKey: .defense)) ?? 0
speed = (try? container.decode(Int.self, forKey: .speed)) ?? 0
dexterity = (try? container.decode(Int.self, forKey: .dexterity)) ?? 0
total = (try? container.decode(Int.self, forKey: .total)) ?? (strength + defense + speed + dexterity)
}
enum CodingKeys: String, CodingKey {
case strength, defense, speed, dexterity, total
}
}
// MARK: - Attack Result
struct AttackResult: Codable, Identifiable {
let code: String?
let timestampStarted: Int?
let timestampEnded: Int?
let attackerId: Int?
let attackerName: String?
let defenderId: Int?
let defenderName: String?
let result: String?
let respect: Double?
let id: String
enum CodingKeys: String, CodingKey {
case code
case timestampStarted = "timestamp_started"
case timestampEnded = "timestamp_ended"
case attackerId = "attacker_id"
case attackerName = "attacker_name"
case defenderId = "defender_id"
case defenderName = "defender_name"
case result, respect
}
init(code: String?, timestampStarted: Int?, timestampEnded: Int?, attackerId: Int?, attackerName: String?, defenderId: Int?, defenderName: String?, result: String?, respect: Double?) {
self.code = code
self.timestampStarted = timestampStarted
self.timestampEnded = timestampEnded
self.attackerId = attackerId
self.attackerName = attackerName
self.defenderId = defenderId
self.defenderName = defenderName
self.result = result
self.respect = respect
self.id = code ?? UUID().uuidString
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
code = try container.decodeIfPresent(String.self, forKey: .code)
timestampStarted = try container.decodeIfPresent(Int.self, forKey: .timestampStarted)
timestampEnded = try container.decodeIfPresent(Int.self, forKey: .timestampEnded)
attackerId = try container.decodeIfPresent(Int.self, forKey: .attackerId)
attackerName = try container.decodeIfPresent(String.self, forKey: .attackerName)
defenderId = try container.decodeIfPresent(Int.self, forKey: .defenderId)
defenderName = try container.decodeIfPresent(String.self, forKey: .defenderName)
result = try container.decodeIfPresent(String.self, forKey: .result)
respect = try container.decodeIfPresent(Double.self, forKey: .respect)
id = code ?? UUID().uuidString
}
func opponentName(forUserId userId: Int) -> String {
let name: String?
if attackerId == userId {
name = defenderName
} else {
name = attackerName
}
if let name = name, !name.isEmpty {
return name
}
return "Someone"
}
func opponentId(forUserId userId: Int) -> Int? {
if attackerId == userId {
return defenderId
} else {
return attackerId
}
}
func wasAttacker(userId: Int) -> Bool {
return attackerId == userId
}
func resultIcon(forUserId userId: Int) -> String {
let userWasAttacker = wasAttacker(userId: userId)
switch result {
case "Attacked": return userWasAttacker ? "checkmark.circle.fill" : "xmark.circle.fill"
case "Mugged": return userWasAttacker ? "dollarsign.circle.fill" : "xmark.circle.fill"
case "Hospitalized": return userWasAttacker ? "cross.circle.fill" : "xmark.circle.fill"
case "Lost": return userWasAttacker ? "xmark.circle.fill" : "shield.checkered"
case "Stalemate": return "equal.circle.fill"
case "Escape": return userWasAttacker ? "figure.run" : "shield.checkered"
case "Assist": return "person.2.fill"
default: return "questionmark.circle"
}
}
func resultColor(forUserId userId: Int) -> Color {
let userWasAttacker = wasAttacker(userId: userId)
switch result {
case "Attacked", "Mugged", "Hospitalized":
return userWasAttacker ? .green : .red
case "Lost":
return userWasAttacker ? .red : .green
case "Stalemate":
return .orange
case "Escape":
return userWasAttacker ? .orange : .green
case "Assist":
return .blue
default:
return .gray
}
}
var timeAgo: String {
guard let ts = timestampEnded else { return "" }
let now = Int(Date().timeIntervalSince1970)
let diff = now - ts
if diff < 3600 { return "\(diff / 60)m" }
if diff < 86400 { return "\(diff / 3600)h" }
return "\(diff / 86400)d"
}
}
// MARK: - Faction Data
struct FactionData: Codable {
let name: String
let factionId: Int
let respect: Int
let chain: FactionChain
enum CodingKeys: String, CodingKey {
case name
case factionId = "ID"
case respect
case chain
}
init(name: String = "", factionId: Int = 0, respect: Int = 0, chain: FactionChain = FactionChain()) {
self.name = name
self.factionId = factionId
self.respect = respect
self.chain = chain
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
name = (try? container.decode(String.self, forKey: .name)) ?? ""
factionId = (try? container.decode(Int.self, forKey: .factionId)) ?? 0
respect = (try? container.decode(Int.self, forKey: .respect)) ?? 0
chain = (try? container.decode(FactionChain.self, forKey: .chain)) ?? FactionChain()
}
}
struct FactionChain: Codable {
let current: Int
let max: Int
let timeout: Int
let cooldown: Int
init(current: Int = 0, max: Int = 0, timeout: Int = 0, cooldown: Int = 0) {
self.current = current
self.max = max
self.timeout = timeout
self.cooldown = cooldown
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
current = (try? container.decode(Int.self, forKey: .current)) ?? 0
max = (try? container.decode(Int.self, forKey: .max)) ?? 0
timeout = (try? container.decode(Int.self, forKey: .timeout)) ?? 0
cooldown = (try? container.decode(Int.self, forKey: .cooldown)) ?? 0
}
enum CodingKeys: String, CodingKey {
case current, max, timeout, cooldown
}
}
// MARK: - Property Info
struct PropertyInfo: Codable, Identifiable {
let id: Int
let propertyType: String
let vault: Int
let upkeep: Int
let rented: Bool
let daysUntilUpkeep: Int
enum CodingKeys: String, CodingKey {
case id = "property_id"
case propertyType = "property"
case vault = "money"
case upkeep, rented
case daysUntilUpkeep = "days_left"
}
init(id: Int = 0, propertyType: String = "", vault: Int = 0, upkeep: Int = 0, rented: Bool = false, daysUntilUpkeep: Int = 0) {
self.id = id
self.propertyType = propertyType
self.vault = vault
self.upkeep = upkeep
self.rented = rented
self.daysUntilUpkeep = daysUntilUpkeep
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = (try? container.decode(Int.self, forKey: .id)) ?? 0
propertyType = (try? container.decode(String.self, forKey: .propertyType)) ?? ""
vault = (try? container.decode(Int.self, forKey: .vault)) ?? 0
upkeep = (try? container.decode(Int.self, forKey: .upkeep)) ?? 0
rented = (try? container.decode(Bool.self, forKey: .rented)) ?? false
daysUntilUpkeep = (try? container.decode(Int.self, forKey: .daysUntilUpkeep)) ?? 0
}
}
// MARK: - Watchlist Item
struct WatchlistItem: Codable, Identifiable {
let id: Int
let name: String
var lowestPrice: Int
var lowestPriceQuantity: Int
var secondLowestPrice: Int
var lastUpdated: Date?
var error: String?
// Explicit memberwise initializer
init(id: Int, name: String, lowestPrice: Int, lowestPriceQuantity: Int, secondLowestPrice: Int, lastUpdated: Date?, error: String?) {
self.id = id
self.name = name
self.lowestPrice = lowestPrice
self.lowestPriceQuantity = lowestPriceQuantity
self.secondLowestPrice = secondLowestPrice
self.lastUpdated = lastUpdated
self.error = error
}
// Custom decoding to handle legacy data missing new fields
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
lowestPrice = try container.decodeIfPresent(Int.self, forKey: .lowestPrice) ?? 0
lowestPriceQuantity = try container.decodeIfPresent(Int.self, forKey: .lowestPriceQuantity) ?? 0
secondLowestPrice = try container.decodeIfPresent(Int.self, forKey: .secondLowestPrice) ?? 0
lastUpdated = try container.decodeIfPresent(Date.self, forKey: .lastUpdated)
error = try container.decodeIfPresent(String.self, forKey: .error)
}
var priceDifference: Int {
guard secondLowestPrice > 0 && lowestPrice > 0 else { return 0 }
return secondLowestPrice - lowestPrice
}
var isLoading: Bool {
lowestPrice == 0 && error == nil
}
}
// MARK: - Update Manager
struct GitHubRelease: Codable {
let tagName: String
let htmlUrl: String
let body: String
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case htmlUrl = "html_url"
case body
}
}
class UpdateManager {
static let shared = UpdateManager()
// Configure your repository here
private let githubOwner = "pawelorzech"
private let githubRepo = "MacTorn"
func checkForUpdates(currentVersion: String) async -> GitHubRelease? {
let urlString = "https://api.github.com/repos/\(githubOwner)/\(githubRepo)/releases/latest"
guard let url = URL(string: urlString) else { return nil }
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
return nil
}
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
// Compare versions
let versionString = release.tagName.replacingOccurrences(of: "v", with: "")
if isVersion(versionString, greaterThan: currentVersion) {
return release
}
} catch {
print("Update check failed: \(error)")
}
return nil
}
private func isVersion(_ newVersion: String, greaterThan currentVersion: String) -> Bool {
let newComponents = newVersion.split(separator: ".").compactMap { Int($0) }
let currentComponents = currentVersion.split(separator: ".").compactMap { Int($0) }
let maxLength = max(newComponents.count, currentComponents.count)
for i in 0..<maxLength {
let new = i < newComponents.count ? newComponents[i] : 0
let current = i < currentComponents.count ? currentComponents[i] : 0
if new > current {
return true
} else if new < current {
return false
}
}
return false
}
}
// MARK: - Error
struct TornError: Codable {
let code: Int
let error: String
}
// MARK: - API Configuration
enum TornAPI {
static let baseURL = "https://api.torn.com/user/"
static let factionURL = "https://api.torn.com/faction/"
static let marketURL = "https://api.torn.com/market/"
static let tornURL = "https://api.torn.com/torn/"
static let selections = "basic,bars,cooldowns,travel,profile,events,messages,money,battlestats,attacks,properties"
static func url(for apiKey: String) -> URL? {
URL(string: "\(baseURL)?selections=\(selections)&key=\(apiKey)")
}
static func factionURL(for apiKey: String) -> URL? {
URL(string: "\(factionURL)?selections=basic,chain&key=\(apiKey)")
}
static func marketURL(itemId: Int, apiKey: String) -> URL? {
// v2 endpoint for item market
URL(string: "https://api.torn.com/v2/market/\(itemId)?selections=itemmarket,bazaar&key=\(apiKey)")
}
}
// MARK: - Notification Settings
struct NotificationRule: Codable, Identifiable, Equatable {
let id: String
var barType: BarType
var threshold: Int // Percentage 0-100
var enabled: Bool
var soundName: String
enum BarType: String, Codable, CaseIterable {
case energy = "Energy"
case nerve = "Nerve"
case happy = "Happy"
case life = "Life"
}
static let defaults: [NotificationRule] = [
NotificationRule(id: "energy_full", barType: .energy, threshold: 100, enabled: true, soundName: "default"),
NotificationRule(id: "energy_high", barType: .energy, threshold: 80, enabled: false, soundName: "default"),
NotificationRule(id: "nerve_full", barType: .nerve, threshold: 100, enabled: true, soundName: "default"),
NotificationRule(id: "happy_full", barType: .happy, threshold: 100, enabled: false, soundName: "default"),
NotificationRule(id: "life_low", barType: .life, threshold: 20, enabled: false, soundName: "default")
]
}
// MARK: - Sound Options
enum NotificationSound: String, CaseIterable {
case `default` = "default"
case ping = "Ping"
case glass = "Glass"
case hero = "Hero"
case pop = "Pop"
case submarine = "Submarine"
case none = "None"
var displayName: String {
switch self {
case .default: return "Default"
case .ping: return "Ping"
case .glass: return "Glass"
case .hero: return "Hero"
case .pop: return "Pop"
case .submarine: return "Submarine"
case .none: return "None"
}
}
}
// MARK: - App Feedback State
struct AppFeedbackState: Codable {
var firstLaunchDate: Date
var hasResponded: Bool
var dismissCount: Int
var lastDismissedDate: Date?
}
// MARK: - Keyboard Shortcuts
struct KeyboardShortcut: Identifiable, Codable, Equatable {
let id: String
var name: String
var url: String
var keyEquivalent: String
var modifiers: [String]
static let defaults: [KeyboardShortcut] = [
KeyboardShortcut(id: "home", name: "Home", url: "https://www.torn.com/", keyEquivalent: "h", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "items", name: "Items", url: "https://www.torn.com/item.php", keyEquivalent: "i", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "gym", name: "Gym", url: "https://www.torn.com/gym.php", keyEquivalent: "g", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "crimes", name: "Crimes", url: "https://www.torn.com/crimes.php", keyEquivalent: "c", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "mission", name: "Missions", url: "https://www.torn.com/missions.php", keyEquivalent: "m", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "travel", name: "Travel", url: "https://www.torn.com/travelagency.php", keyEquivalent: "t", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "hospital", name: "Hospital", url: "https://www.torn.com/hospitalview.php", keyEquivalent: "o", modifiers: ["command", "shift"]),
KeyboardShortcut(id: "faction", name: "Faction", url: "https://www.torn.com/factions.php", keyEquivalent: "f", modifiers: ["command", "shift"])
]
}